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/DoctrineMongoDBBundleRun the vendors script to install DoctrineMongoDBBundle:
php vendors/vendors installOnce 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/UserBundleRun the vendors script to install FOSUserBundle:
php vendors/vendors installAdd 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: /groupCreate a new user bundle that is going to extend the FOSUserBundle:
php app/console generate:bundle --namespace=Acme/UserBundleUpdate 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.