Symfony2 Doctrine Migrations with Unique Indexes (Slugs)
This is something me and my colleagues encounter from time to time.
The Problem
One of the problems of adding unique indexes to existing data, like adding the sluggable Doctrine behaviour using the StofDoctrineExtensionsBundle, is that the generated migration will end up throwing an error:
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '' for key 'UNIQ_BDAFD8C8989D9B62'
Causes
Since the default values for the new MySQL column are not unique, adding the unique index is not possible - which is what the error above is telling us. So we will need to change the migration to also generate the unique values before adding the index.
Solution
In order to do so, we will have to split the generated migration into 2 different migrations, one for adding the new column, which could be a slug, and the other to add the unique index. After running the first migration, we need to execute the code that generates the unique values needed for the index. We can use the postUp
method in a Doctrine migration to execute code after the “up” migration finished. We will also need to instantiate and boot the kernel in order to gain access to the Symfony framework and build our functionality like we would do in a controller:
<?php namespace Application\Migrations; use Doctrine\DBAL\Migrations\AbstractMigration; use Doctrine\DBAL\Schema\Schema; /** * Auto-generated Migration: Please modify to your needs! */ class Version20151021133750 extends AbstractMigration { protected static $class = 'AppKernel'; protected static $kernel; /** * Creates a Kernel. * * Available options: * * * environment * * debug * * @param array $options An array of options * * @return HttpKernelInterface A HttpKernelInterface instance */ protected static function createKernel(array $options = array()) { if (null === static::$class) { static::$class = static::getKernelClass(); } return new static::$class( isset($options['environment']) ? $options['environment'] : 'test', isset($options['debug']) ? $options['debug'] : true ); } /** * Creates a Client. * * @param array $options An array of options to pass to the createKernel class * @param array $server An array of server parameters * * @return Client A Client instance */ protected static function createClient(array $options = array(), array $server = array()) { if (null !== static::$kernel) { static::$kernel->shutdown(); } static::$kernel = static::createKernel($options); static::$kernel->boot(); $client = static::$kernel->getContainer()->get('test.client'); $client->setServerParameters($server); return $client; } /** * @param Schema $schema */ public function up(Schema $schema) { // this up() migration is auto-generated, please modify it to your needs $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); $this->addSql('ALTER TABLE book ADD slug VARCHAR(128) DEFAULT ""'); //$this->addSql('CREATE UNIQUE INDEX UNIQ_CBE5A331989D9B62 ON book (slug)'); } public function postUp(Schema $schema) { $this->client = self::createClient(); $this->em = $this->client->getKernel()->getContainer()->get('doctrine')->getEntityManager(); $books = $this->em->getRepository('AppBundle:Book')->findAll(); foreach($books as $book){ // need this so we force the generation of a new slug $book->setSlug(null); $this->em->persist($book); } $this->em->flush(); } /** * @param Schema $schema */ public function down(Schema $schema) { // this down() migration is auto-generated, please modify it to your needs $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); //$this->addSql('DROP INDEX UNIQ_CBE5A331989D9B62 ON book'); $this->addSql('ALTER TABLE book DROP slug'); } }
For the second migration file we only add the code necessary to add/remove the unique indexes:
<?php namespace Application\Migrations; use Doctrine\DBAL\Migrations\AbstractMigration; use Doctrine\DBAL\Schema\Schema; /** * Auto-generated Migration: Please modify to your needs! */ class Version20151021141028 extends AbstractMigration { /** * @param Schema $schema */ public function up(Schema $schema) { // this up() migration is auto-generated, please modify it to your needs $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); $this->addSql('ALTER TABLE book CHANGE slug slug VARCHAR(128) NOT NULL'); $this->addSql('CREATE UNIQUE INDEX UNIQ_CBE5A331989D9B62 ON book (slug)'); } /** * @param Schema $schema */ public function down(Schema $schema) { // this down() migration is auto-generated, please modify it to your needs $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); $this->addSql('DROP INDEX UNIQ_CBE5A331989D9B62 ON book'); $this->addSql('ALTER TABLE book CHANGE slug slug VARCHAR(128) DEFAULT \'\' COLLATE utf8_unicode_ci'); } }
You can generate an empty migration file using the doctrine:migratios:generate
command.
If you now run the doctrine:migrations:migrate
command everything should be fine and the database should be populated with the unique values we needed in the first place.
Conclusion
Luckily I solved this issue before an important deadline. Let me know if you found any other way around it, or a quicker solution to this issue.
How to Create a Custom Symfony2 Password Encoder
As you advance through your Symfony2 developer life, you will probably encounter the need to create a custom Symfony2 password encoder for you project. One of the most common reasons to do this, is when you migrate from an old project (different technology) and you have to keep users together with their working passwords. As you probably cannot find out the plain passwords to be able to just save them to your new database, you will need to replicate the algorithm used to encode them so they will keep working when the transition is over.
How to Create a Custom Symfony2 Password Encoder
In order to add a new, custom, password encoder to your Symfony2 project, you will need to create the encoder class, register it as a service and then specify it in the security.yml configuration file of your project.
Below you will find the necessary code to implement this:
AppBundle/Security/Core/Encoder/MyPasswordEncoder.php
<?php namespace AppBundle\Security\Core\Encoder; use Symfony\Component\Security\Core\Encoder\BasePasswordEncoder; use Symfony\Component\Security\Core\Exception\BadCredentialsException; class MyPasswordEncoder extends BasePasswordEncoder { private $ignorePasswordCase; /** * Constructor. * * @param bool $ignorePasswordCase Compare password case-insensitive */ public function __construct($ignorePasswordCase = false) { $this->ignorePasswordCase = $ignorePasswordCase; } /** * {@inheritdoc} */ public function encodePassword($raw, $salt) { if ($this->isPasswordTooLong($raw)) { throw new BadCredentialsException('Invalid password.'); } return sha1($this->mergePasswordAndSalt($raw, $salt)); } /** * {@inheritdoc} */ public function isPasswordValid($encoded, $raw, $salt) { if ($this->isPasswordTooLong($raw)) { return false; } try { $pass2 = $this->encodePassword($raw, $salt); } catch (BadCredentialsException $e) { return false; } if (!$this->ignorePasswordCase) { return $this->comparePasswords($encoded, $pass2); } return $this->comparePasswords(strtolower($encoded), strtolower($pass2)); } /** * Merges a password and a salt. * * @param string $password the password to be used * @param string $salt the salt to be used * * @return string a merged password and salt * * @throws \InvalidArgumentException */ protected function mergePasswordAndSalt($password, $salt) { if (empty($salt)) { return $password; } return $salt.$password; } }
app/config/services.yml
services: app.my_password_encoder: class: AppBundle\Security\Core\Encoder\MyPasswordEncoder
app/config/security.yml
security: encoders: FOS\UserBundle\Model\UserInterface: id: app.my_password_encoder
How to Fix Symfony2 Ajax Login Redirect
You probably noticed that sometimes an Ajax request will return the login page instead of the actual content is should return. This happens when the user has beed logged out in the background and the current page does not reflect that (it could happen if the session expired or if the user simply logged out from another browser window/tab).
How to Fix Symfony2 Ajax Login Redirect
Here's a quick way to fix this: we will create an event listener that will catch this authentication exception, check for an Ajax request and, if found, it will return a 403 http code instead of redirecting to the login page. The JavaScript code will then know to reload the page and thus redirect to login in case of 403 instead of loading and showing the received content to the user.
Here's the Symfony2 event listener:
<?php // src/AppBundle/EventListener/AjaxAuthenticationListener.php namespace AppBundle\EventListener; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; /** */ class AjaxAuthenticationListener { /** * Handles security related exceptions. * * @param GetResponseForExceptionEvent $event An GetResponseForExceptionEvent instance */ public function onCoreException(GetResponseForExceptionEvent $event) { $exception = $event->getException(); $request = $event->getRequest(); if ($request->isXmlHttpRequest()) { if ($exception instanceof AuthenticationException || $exception instanceof AccessDeniedException) { $event->setResponse(new Response('', 403)); } } } }
As always, we will have to register it as a service:
services: ajax.authentication.listener: class: AppBundle\EventListener\AjaxAuthenticationListener tags: - { name: kernel.event_listener, event: kernel.exception, method: onCoreException, priority: 1000 }
How to Fix Symfony2 Ajax Login Redirect
In the JavaScript code we add the following to make jQuery treat the Ajax errors by reloading the window in case of a 403 error. What will actually happen is that the user will end on the login page as he is no longer authenticated.
$(document).ready(function() { $(document).ajaxError(function (event, jqXHR) { if (403 === jqXHR.status) { window.location.reload(); } }); });
How To Enable Email Confirmation On Fosuserbundle Profile Edit
We all know and use FOSUserBundle in our Symfony applications, so much it became kind of a standard. It provides everything you need for user management: login, registration, email confirmation and much more control over the access of the user in your application. But we found a thing missing from this awesome package: email confirmation after the initial email address has been changed through a profile edit. In the following lines we will show you how to extend the FOSUserBundle to implement this.
How To Enable Email Confirmation On Fosuserbundle Profile Edit
This post assumes you are familiar (even advanced) with the Symfony framework and FOSUserBundle.
To get started we will need a listener to be triggered when a profile edit has happened, FOSUserBundle fires two events that we are interested in: FOSUserEvents::PROFILE_EDIT_INITIALIZE
and FOSUserEvents::PROFILE_EDIT_SUCCESS
. The first one is triggered before the actual profile data is changed so we will use that to get a hold on the original email address. When the second event is fired, we will compare the initial email address with the current one and, if they are not the same, we will start the confirmation process:
<?php // src/AppBundle/EventListener/ProfileEditListener.php namespace AppBundle\EventListener; use FOS\UserBundle\FOSUserEvents; use FOS\UserBundle\Event\FormEvent; use FOS\UserBundle\Event\GetResponseUserEvent; use FOS\UserBundle\Mailer\MailerInterface; use FOS\UserBundle\Util\TokenGeneratorInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\HttpFoundation\Session\SessionInterface; class ProfileEditListener implements EventSubscriberInterface { private $oldEmail; private $mailer; private $tokenGenerator; private $router; private $session; private $tokenStorage; public function __construct(MailerInterface $mailer, TokenGeneratorInterface $tokenGenerator, UrlGeneratorInterface $router, SessionInterface $session, TokenStorageInterface $tokenStorage) { $this->mailer = $mailer; $this->tokenGenerator = $tokenGenerator; $this->router = $router; $this->session = $session; $this->tokenStorage = $tokenStorage; } public static function getSubscribedEvents() { return array( FOSUserEvents::PROFILE_EDIT_INITIALIZE => 'onProfileEditInitialize', FOSUserEvents::PROFILE_EDIT_SUCCESS => 'onProfileEditSuccess' ); } public function onProfileEditInitialize(GetResponseUserEvent $event) { $this->oldEmail = $event->getUser()->getEmail(); } public function onProfileEditSuccess(FormEvent $event) { $user = $event->getForm()->getData(); if ($user->getEmail() !== $this->oldEmail) { // disable user $user->setEnabled(false); // send confirmation token to new email $user->setConfirmationToken($this->tokenGenerator->generateToken()); $this->mailer->sendConfirmationEmailMessage($user); // force user to log-out $this->tokenStorage->setToken(); // redirect user to check email page $this->session->set('fos_user_send_confirmation_email/email', $user->getEmail()); $url = $this->router->generate('fos_user_registration_check_email'); $event->setResponse(new RedirectResponse($url)); } } }
Now, add this to your services.yml
file and you're good to go:
app.profile_edit_listener: class: AppBundle\EventListener\ProfileEditListener arguments: [@fos_user.mailer, @fos_user.util.token_generator, @router, @session, @security.token_storage] tags: - { name: kernel.event_subscriber }
How To Enable Email Confirmation On Fosuserbundle Profile Edit
One last thing: you will probably want to change the email template that is sent to the user with the confirmation link. You can overwrite it by creating app/Resources/FOSUserBundle/views/Registration/email.txt.twig
and put what you need in there (use the original one from vendor/friendsofsymfony/user-bundle/Resources/views/Registration/email.txt.twig
to see how to get the confirmation link).
How to Store Latitude and Longitude in MySQL
As we know, a latitude value can be between -90 and +90 degrees, whereas a longitude value can be between -180 and +180 degrees. To get accuracy to within a meter, only six decimal places are needed, which is sufficient for most cases. But you can go even further by using eight places which will give you more than centimeter-level accuracy.Read more
Symfony2: Doctrine 2 Entity Listeners
When working on a little more complex application is inevitable that we'll get to the point when we need to trigger an action when something happens somewhere in our application. Quite often is the case that we need to bind our actions to an entity, like for example notify all subscribers to a blog post that the post they are following has changed.
Symfony2: Forms and Ajax
Making Ajax calls is a trivial task nowadays for any web developer, and I'm sure it's no enigma for all of you how to do it using your favourite Javascript library or framework like jQuery, Angular, Backbone, you name it.
Forms and ajax
But how do we handle these requests in the backend? More specifically, how should we write our controllers to take full advantage of our preferred framework, Symfony 2 in my case, and create a great UX for our consumers (with errors displayed nicely and effortless) and a great feeling for our colleagues with whom we share the code (by using status codes and promises).
How to Add Share Buttons to Symfony2 Generated Web Pages
Some projects require you to add social share buttons for their pages. I'm gonna show you a simple way to do this using the Socialitejs JavaScript library.
First you're gonna have to download the Socialitejs library, save it to your bundle Resources/public/js
folder and publish the assets.
If you have a general layout that is inherited by all your specific page templates (like it's usually the case), just add the share code in it like so:Read more
Symfony2: Gedmo Softdeletable Doctrine Entities With Unique Index Columns
There is a problem when using the SoftDeletable Doctrine extension for entities that have some unique index columns. The most obvious example is User entities. An User has at least one column that needs to be unique (username and/or email). When soft-deleting an User the actual record will stay in the db table. Now if you try to create a new one with the same username/email, the validation will pass (doctrine will not see the existing soft-deleted one) but the database layer will not be able to save the new record:
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry ... for key …
Symfony2: How To Get The Label For A Specific Form Field In Twig
If you need to render the label
value for a specific field without rendering the entire HTML label
element (e. g. to use it for a placeholder), you can do it by using the label
form variable:
{{ form_widget(form.fieldName, {'attr': {'placeholder’: form.fieldName.vars.label}}) }}