Saturday, August 27, 2011

Symfony 2 + DoctrineMongoDBBundle + FOSUserBundle Tutorial

It's been a while I have not added an entry to my blog. I have been busy playing with Symfony 2, MongoDB, and FOSUserBundle for a while now so here is a tutorial to integrate Symfony 2, MongoDB, and FOSUserBundle.

Objectives

* Install DoctrineMongoDBBundle
* Install FOSUserBundle
* Create user model
** Utilize groups
** Add additional properties to user model
* Create customized registration form and handler

At this point I am going to assume that you have a running Symfony 2 and MongoDB installation in place already and a basic understanding of how to configure Symfony 2 services. I will also exclude view related steps from this tutorial.

Setting Up DoctrineMongoDBBundle

Add the following to your deps file:
[doctrine-mongodb]
    git=http://github.com/doctrine/mongodb.git

[doctrine-mongodb-odm]
    git=http://github.com/doctrine/mongodb-odm.git

[DoctrineMongoDBBundle]
    git=http://github.com/symfony/DoctrineMongoDBBundle.git
    target=/bundles/Symfony/Bundle/DoctrineMongoDBBundle
Run the vendors script to install DoctrineMongoDBBundle:
php vendors/vendors install
Once the installation is complete, update your app/AppKernel.php file to register your DoctrineMongoDBBundle:
public function registerBundles()
{
    $bundles = array(
        new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
        new Symfony\Bundle\SecurityBundle\SecurityBundle(),
        new Symfony\Bundle\TwigBundle\TwigBundle(),
        new Symfony\Bundle\MonologBundle\MonologBundle(),
        new Symfony\Bundle\SwiftmailerBundle\SwiftmailerBundle(),
        new Symfony\Bundle\DoctrineBundle\DoctrineBundle(),
        new Symfony\Bundle\AsseticBundle\AsseticBundle(),
        new Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(),
        new JMS\SecurityExtraBundle\JMSSecurityExtraBundle(),
        new Symfony\Bundle\DoctrineMongoDBBundle\DoctrineMongoDBBundle(),
    );

    if (in_array($this->getEnvironment(), array('dev', 'test'))) {
        $bundles[] = new Acme\DemoBundle\AcmeDemoBundle();
        $bundles[] = new Symfony\Bundle\WebProfilerBundle\WebProfilerBundle();
        $bundles[] = new Sensio\Bundle\DistributionBundle\SensioDistributionBundle();
        $bundles[] = new Sensio\Bundle\GeneratorBundle\SensioGeneratorBundle();
    }

    return $bundles;
}
Now, let's update the app/autoload.php file:
AnnotationRegistry::registerLoader(function($class) use ($loader) {
    $loader->loadClass($class);
    return class_exists($class, false);
});

AnnotationRegistry::registerFile(__DIR__.'/../vendor/doctrine-mongodb-odm/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/DoctrineAnnotations.php');
The AnnotationRegistry::registerLoader() call simply registers Symfony loader in Doctrine with a simple anonymous callback function. This is all fine as long as you have a single class definition per file along with consistent namespacing; however, if this is not the case as can be seen in DoctrineAnnotations.php, you need to include the file separately as shown above via the AnnotationRegistry::registerFile() call. Otherwise, you will end up receiving error messages related to auto-loading:
[Semantical Error] The annotation "@Sensio\Bundle\FrameworkExtraBundle\Configuration\Route" in class Acme\DemoBundle\Controller\SecuredController does not exist, or could not be auto-loaded.  
Finally, update your configuration file with your connection information:
doctrine_mongodb:
    connections:
        default:
            server: mongodb://localhost:27017
            options:
                connect: true
    default_database: acme
    document_managers:
        default:
            auto_mapping: true

Setting up FOSUserBundle

FOSUserBundle installation steps are the same as the ones for Doctrine-MognoDB package. First, update your deps file:
[FOSUserBundle]
    git=git://github.com/FriendsOfSymfony/FOSUserBundle.git
    target=/bundles/FOS/UserBundle
Run the vendors script to install FOSUserBundle:
php vendors/vendors install
Add the FOSUserBundle routes to your app/config/routing.yml file:
fos_user_profile:
    resource: "@FOSUserBundle/Resources/config/routing/profile.xml"
    prefix: /user/profile

fos_user_register:
    resource: "@FOSUserBundle/Resources/config/routing/registration.xml"
    prefix: /user/register

fos_user_resetting:
    resource: "@FOSUserBundle/Resources/config/routing/resetting.xml"
    prefix: /user/resetting

fos_user_change_password:
    resource: "@FOSUserBundle/Resources/config/routing/change_password.xml"
    prefix: /user/change-password
    
fos_user_security:
    resource: "@FOSUserBundle/Resources/config/routing/security.xml"
    prefix: /user
    
fos_user_group:
    resource: "@FOSUserBundle/Resources/config/routing/group.xml"
    prefix: /group
Create a new user bundle that is going to extend the FOSUserBundle:
php app/console generate:bundle --namespace=Acme/UserBundle
Update src/Acme/UserBundle/AcmeUserBundle.php and add a getParent() method as illustrated below:
namespace Acme\UserBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;

class AcmeUserBundle extends Bundle
{
    public function getParent()
    {
        return 'FOSUserBundle';
    }
}
Add your new bundles (FOSUserBundle and AcmeUserBundle) to your app/AppKernel.php:
public function registerBundles()
{
    $bundles = array(
        new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
        new Symfony\Bundle\SecurityBundle\SecurityBundle(),
        new Symfony\Bundle\TwigBundle\TwigBundle(),
        new Symfony\Bundle\MonologBundle\MonologBundle(),
        new Symfony\Bundle\SwiftmailerBundle\SwiftmailerBundle(),
        new Symfony\Bundle\DoctrineBundle\DoctrineBundle(),
        new Symfony\Bundle\AsseticBundle\AsseticBundle(),
        new Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(),
        new JMS\SecurityExtraBundle\JMSSecurityExtraBundle(),
        new Symfony\Bundle\DoctrineMongoDBBundle\DoctrineMongoDBBundle(),
        new FOS\UserBundle\FOSUserBundle(),
        new Acme\UserBundle\AcmeUserBundle(),
    );

    if (in_array($this->getEnvironment(), array('dev', 'test'))) {
        $bundles[] = new Acme\DemoBundle\AcmeDemoBundle();
        $bundles[] = new Symfony\Bundle\WebProfilerBundle\WebProfilerBundle();
        $bundles[] = new Sensio\Bundle\DistributionBundle\SensioDistributionBundle();
        $bundles[] = new Sensio\Bundle\GeneratorBundle\SensioGeneratorBundle();
    }

    return $bundles;
}
Now, create your model files under the src/Acme/UserBundle/Document directory. Again, we are going to extend the base classes provided in the FOSUserBundle.

src/Acme/UserBundle/Document/User.php
namespace Acme\UserBundle\Document;

use FOS\UserBundle\Document\User as BaseUser;

class User extends BaseUser
{
    protected $sex;
    protected $dob;
    protected $termsOfService;
    protected $groups = array();

    public function __construct()
    {
        parent::__construct();
        $this->groups = new \Doctrine\Common\Collections\ArrayCollection();
    }

    /**
     * Set dob
     *
     * @param date $dob
     */
    public function setDob($dob)
    {
        $this->dob = $dob;
    }

    /**
     * Get dob
     *
     * @return date $dob
     */
    public function getDob()
    {
        return $this->dob;
    }

    /**
     * Set sex
     *
     * @param string $sex
     */
    public function setSex($sex)
    {
        $this->sex = $sex;
    }

    /**
     * Get sex
     *
     * @return string $sex
     */
    public function getSex()
    {
        return $this->sex;
    }

    /**
     * Add groups
     *
     * @param Acme\UserBundle\Document\Group $groups
     */
    public function addGroups(\Acme\UserBundle\Document\Group $groups)
    {
        $this->groups[] = $groups;
    }

    /**
     * Get groups
     *
     * @return Doctrine\Common\Collections\Collection $groups
     */
    public function getGroups()
    {
        return $this->groups;
    }

    /**
     * Set termsOfService
     *
     * @param boolean $termsOfService
     */
    public function setTermsOfService($termsOfService)
    {
        $this->termsOfService = $termsOfService;
    }

    /**
     * Get termsOfService
     *
     * @return boolean $termsOfService
     */
    public function getTermsOfService()
    {
        return $this->termsOfService;
    }
}
src/Acme/UserBundle/Document/Group.php
namespace Acme\UserBundle\Document;

use FOS\UserBundle\Document\Group as BaseGroup;

class Group extends BaseGroup
{

}
A couple of things to remember at this point. I have added custom properties to my user class. These are the additional properties that I would like to store in my MongoDB collection. There is one exception: "termsOfService". This property is not going to be persisted; however, it is needed since Symfony 2 utilizes models for form validation purposes.

Another thing to notice is the use of "groups" property. I am going to use this field to assign groups - which are basically collection of roles - to my users.

Lastly, I have not added the "id" property to my models. The reason is simple; I am not going to use annotations to map my class properties. Instead, I will use the XML configuration as seen in vendor/bundles/FOS/UserBundle/Resources/config/doctrine/*.mongodb.xml to keep it consistent.

src/Acme/UserBundle/Resources/config/doctrine/User.mongodb.xml
<doctrine-mongo-mapping xmlns="http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:schemaLocation="http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping
                    http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping.xsd">

    <document name="Acme\UserBundle\Document\User" db="acme" collection="user" customId="true">
        
        <field fieldName="id" id="true" strategy="INCREMENT" />
        <field fieldName="dob" type="date" />
        <field fieldName="sex" type="string" />
        
        <reference-many target-document="Acme\UserBundle\Document\Group" field="groups"> />
       
    </document>
    
</doctrine-mongo-mapping>
Here, I have defined the "id" property as an increment type and mapped the "dob" and "sex" properties to the collection. You can also see how the "groups" property references entries in another collection in MongoDB. Finally, as explained previously, I have excluded the termsOfService field since I do not want it stored.

src/Acme/UserBundle/Resources/config/doctrine/Group.mongodb.xml
<doctrine-mongo-mapping xmlns="http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:schemaLocation="http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping
                    http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping.xsd">

    <document name="Acme\UserBundle\Document\Group" db="acme" collection="group" customId="true">
        <field fieldName="id" id="true" strategy="INCREMENT" />
    </document>
    
</doctrine-mongo-mapping>
Now that we are done with data mapping, we can move on to dealing with data collection. Create a new form type class at src/Acme/UserBundle/Form/Type/RegistrationFormType.php:
namespace Acme\UserBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;

class RegistrationFormType extends AbstractType
{
    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder
            ->add('username', null, array(
                'attr' => array('class' => 'validate[required]'),
            ))
            ->add('email', null, array(
                'attr' => array('class' => 'validate[required,custom[email]]'),
            ))
            ->add('dob', 'date', array(
                'years' => range(date('Y') - 90, date('Y')),
                'data_timezone' => 'UTC',
                'user_timezone' => 'UTC'
            ))
            ->add('sex', 'choice', array(
                'choices'   => array('m' => 'Male', 'f' => 'Female'),
                'expanded' => true,
                'attr' => array('class' => 'validate[required]'),
            ))
            ->add('plainPassword', 'repeated', array(
                'type' => 'password',
                'first_name' => 'Password',
                'second_name' => 'Confirm Password',
                'attr' => array('class' => 'validate[required]'),
            ))
            ->add('termsOfService', 'checkbox', array(
                'label' => 'I agree to the terms and service',
            ));
        
    }

    public function getName()
    {
        return 'fos_user_registration';
    }
}
Next step is to validate our form. Create a new XML file located at src/Acme/UserBundle/Resources/config/validation.xml:
<?xml version="1.0" ?>
<constraint-mapping xmlns="http://symfony.com/schema/dic/constraint-mapping"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/constraint-mapping
        http://symfony.com/schema/dic/services/constraint-mapping-1.0.xsd">

    <class name="Acme\UserBundle\Document\User">

        <property name="username">
            <constraint name="Regex">
                <option name="pattern">/^[0-9a-z]+$/i</option>
                <option name="message">Username should consist of alpha-numeric characters only</option>
                <option name="groups">
                    <value>Registration</value>
                    <value>Profile</value>
                </option>
            </constraint>
        </property>
        
        <property name="sex">
            <constraint name="NotBlank">
                <option name="message">Please select sex</option>
                <option name="groups">
                    <value>Registration</value>
                    <value>Profile</value>
                </option>
            </constraint>
        </property>

        <property name="plainPassword">
            <constraint name="MinLength">
                <option name="limit">8</option>
                <option name="message">Password should be at least 8 characters</option>
                <option name="groups">
                    <value>Registration</value>
                    <value>Profile</value>
                </option>
            </constraint>
        </property>
        
        <property name="termsOfService">
            <constraint name="True">
                <option name="message">Please agree to our terms and conditions</option>
                <option name="groups">
                    <value>Registration</value>
                    <value>Profile</value>
                </option>
            </constraint>
        </property>
        
    </class>

</constraint-mapping>
As you can see in the validation file, I have updated the validation rules and messages for existing properties such as "username" and added new validation rules for properties such as "termsOfService".

We now have custom a custom form implemented. The next step would be to create a new handler for this form. It is possible to use the default handler provided in FOSUserBundle; however, in this case I have to create custom one to be able to populate the "groups" property in the user class.

Create a new form type class at src/Acme/UserBundle/Form/Type/RegistrationFormHandler.php:
namespace Acme\UserBundle\Form\Handler;

use FOS\UserBundle\Form\Handler\RegistrationFormHandler as BaseRegistrationFormHandler;
use Symfony\Component\Form\Form;
use Symfony\Component\HttpFoundation\Request;
use FOS\UserBundle\Model\UserManagerInterface;
use FOS\UserBundle\Model\UserInterface;
use FOS\UserBundle\Mailer\MailerInterface;
use FOS\UserBundle\Model\GroupManagerInterface;

class RegistrationFormHandler extends BaseRegistrationFormHandler
{
    protected $groupManager;
    
    public function __construct(Form $form, Request $request, UserManagerInterface $userManager, MailerInterface $mailer, GroupManagerInterface $groupManager)
    {
        parent::__construct($form, $request, $userManager, $mailer);
        $this->groupManager = $groupManager;
    }
    
    protected function onSuccess(UserInterface $user, $confirmation)
    {
        $memberGroup = $this->groupManager->findGroupByName('member');
        
        $user->addGroup($memberGroup);
        
        parent::onSuccess($user, $confirmation);
    }
    
}
In our handler, we have overridden two methods of the default registration form handler provided in FOSUserBundle: the constructor and the onSuccess method. The onSuccess method is overridden because I want to assign a default group to my users when they register. This is possible but it would not work in the default setup since the the group manager service is not available for our handler. The solution is to update our constructor and our service definition as described below.

src/Acme/UserBundle/Resources/config/services.xml:
<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        
        <service id="acme.registration.form.type" class="Acme\UserBundle\Form\Type\RegistrationFormType">
            <tag name="form.type" alias="acme_user_registration_form_type" />
            <argument>%fos_user.model.user.class%</argument>
        </service>
        
        <service id="acme.registration.form.handler" class="Acme\UserBundle\Form\Handler\RegistrationFormHandler" scope="request" public="false">
            <argument type="service" id="fos_user.registration.form" />
            <argument type="service" id="request" />
            <argument type="service" id="fos_user.user_manager" />
            <argument type="service" id="fos_user.mailer" />
            <argument type="service" id="fos_user.group_manager" />
        </service>
        
    </services>
    
</container>
This little XML file summarizes the major paradigm shift from Symfony 1 to Symfony 2. Pretty much every class in our bundle is defined as a service. If you need to access a resource, simply update the the service definition and "inject" the required resource to another service - hence the term "dependency injection". The important point is to understand how these service definition files and the service container work together.

The last step is to set up our security.yml and bind everything together in our config.yml file.

app/config/security.yml
security:
    encoders:
        Symfony\Component\Security\Core\User\User: plaintext
        
    role_hierarchy:
        ROLE_ADMIN:       ROLE_USER
        ROLE_SUPER_ADMIN:  ROLE_ADMIN
        
    providers:
        fos_userbundle:
            id: fos_user.user_manager

    firewalls:
        main:
            pattern:      .*
            form_login:
                provider:       fos_userbundle
                login_path:     /user/login
                use_forward:    false
                check_path:     /user/login_check
                failure_path:   null
            logout:       true
            anonymous:    true

        dev:
            pattern:  ^/(_(profiler|wdt)|css|images|js)/
            security: false

    access_control:
        # The WDT has to be allowed to anonymous users to avoid requiring the login with the AJAX request
        - { path: ^/_wdt/, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/_profiler/, role: IS_AUTHENTICATED_ANONYMOUSLY }
        # AsseticBundle paths used when using the controller for assets
        - { path: ^/js/, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/css/, role: IS_AUTHENTICATED_ANONYMOUSLY }
        # URL of FOSUserBundle which need to be available to anonymous users
        - { path: ^/user/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
#        - { path: ^/user/logout, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/user/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/user/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
        # Secured part of the site
        # This config requires being logged for the whole site and having the admin role for the admin part.
        # Change these rules to adapt them to your needs
        - { path: ^/admin/, role: ROLE_ADMIN }
        - { path: ^/.*, role: ROLE_USER }
app/config/config.yml
fos_user:
    db_driver: mongodb
    firewall_name: main
    user_class: Acme\UserBundle\Document\User
    group:
        group_class: Acme\UserBundle\Document\Group
    from_email:
        address: info@acme.com
        sender_name: Acme
    encoder:
        algorithm: sha512
        encode_as_base64: false
        iterations: 10
    template:
        engine: php
    registration:
        confirmation:
            enabled: true
            template: AcmeUserBundle:Registration:email.txt.php
        form:
            type: acme_user_registration_form_type
            handler: acme.registration.form.handler
    resetting:
        token_ttl: 86400
        email:
            template: AcmeUserBundle:Resetting:email.txt.php

Conclusion

Overall, Symfony 2 is impressive. Having used Symfony 1.0, 1.2, and 1.4, I was looking for PHP5.3 and Doctrine 2 support. The new service container concept is elegant and, simply put, icing on the cake. On the other hand, having never used a similar component like sfGuardPlugin, I was challenged by FOSUserBundle. After all is said and done though, it is a welcome component that I will be using from now on.

Below is a couple of minor issues/questions that have come up while I was writing this post:

* Why can't I use a service id (acme.registration.form.type) for fos_user.registration.form.type? Why do I have to create an alias?
* Twig... Reminds me of my Smarty days. I want nothing to do with it. Yet, it is given priority over PHP templates as can be seen in the Symfony 2 documentation. Obviously, this ends up affecting the bundle developers. Maybe fos_user.template.engine can support an array like [php, twig] so I don't have to rewrite each template in PHP again?
* Multiple configuration schemes (XML, YAML, etc) sound really good until you have to actually deal with them. I wanted to stick with YAML until I realized XML was the default configuration scheme for FOSUserBundle.
* What is the purpose of storing non-canonicalized versions of email and username? I have always stored the normalized version of usernames and emails.