Wednesday, March 28, 2012

Geocoding in Symfony2 and Doctrine2


I needed to add latitude and longitude fields in an entity so I could plot it to a map and figure distances etc. This is a pretty common thing but I wanted to make it as simple and elegant as possible using Symfony2 and Doctrine2.
To start with I selected a geocoding library instead of trying to roll my own, remember we are keeping this simple. I choose Geocoder which has great features and is dead simple to use. First we need to install the Geocoder and an http adapter for it (I used Buzz a great project on its own) into our Symfony2 project.
# deps
[geocoder]
    git=http://github.com/willdurand/Geocoder.git

[buzz]
    git=http://github.com/kriswallsmith/Buzz.git
# app/autoload.php
$loader->registerNamespaces(array(
    //....
    'Geocoder'         => __DIR__.'/../vendor/geocoder/src',
    'Buzz'             => __DIR__.'/../vendor/buzz/lib',
));
and then run
php bin/vendors install
That's it for install, easy right? Now to use it. You could just follow the docs and create an instance whenever you needed to use but this would require you configure it each time; not what I call elegant. Symfony2's Service Container or DIC (dependency injection container) to the rescue.
# app/config/config.yml
services:
  geocoder.adapter:
    class:  Geocoder\HttpAdapter\BuzzHttpAdapter
  geocoder.address:
    class:  Geocoder\Provider\GoogleMapsProvider
    arguments: [@geocoder.adapter]
  geocoder.ip:
    class:  Geocoder\Provider\FreeGeoIpProvider
    arguments: [@geocoder.adapter]
Symfony2's Service Container is a great feature you can find out more here. Now we can use this service from a controller action or anywhere with the service container.
$geocoder = $this->get('geocoder.address');
var_dump($geocoder->getGeocodedData('41 East 4th Street Cookeville, TN 38501'));

var_dump($this->get('geocoder.ip')->getGeocodedData('8.8.8.8'));
Now with only a couple of minutes of work, we have it working and able to geocode physical addresses or ip addresses. Now I could edit the entity's controller action and set the latitude and longitude whenever it was saved but I wanted something more reusable and elegant. Enter Doctrine2 events.
# src/MS/RentrBundle/Doctrine/Event/GeocoderEventSubscriber.php
namespace MS\RentrBundle\Doctrine\Event;

use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Geocoder\Provider\ProviderInterface;

/**
 * Subscribes to Doctrine prePersist and preUpdate to update an Apartment's latitude and longitude
 *
 * @author msmith
 */
class GeocoderEventSubscriber implements EventSubscriber {
    protected $geocoder;
    
    public function __construct(ProviderInterface $geocoder){
        $this->geocoder = $geocoder;
    }

    /**
    * Specifies the list of events to listen
    *
    * @return array
    */
    public function getSubscribedEvents(){
        return array(
            'prePersist',
            'preUpdate',
        );
    }
    
    /**
     * Sets a new Apartment's latitude and longitude if not present 
     * 
     * @param LifecycleEventArgs $eventArgs 
     */
    public function prePersist(LifecycleEventArgs $eventArgs){
        if(($apartment = $eventArgs->getEntity()) instanceof \MS\RentrBundle\Entity\Apartment){
            if( !$apartment->latitude || !$apartment->longitude){
                $this->geocodeApartment($apartment);
            }
        }
    }
    
    /**
     * Sets an updating Apartment's latitude and longitude if not present 
     * or any part of address updated
     * 
     * @param PreUpdateEventArgs $eventArgs 
     */
    public function preUpdate(PreUpdateEventArgs $eventArgs){
        if(($apartment = $eventArgs->getEntity()) instanceof \MS\RentrBundle\Entity\Apartment){
            if( !$apartment->latitude || !$apartment->longitude 
                || $eventArgs->hasChangedField('street') || $eventArgs->hasChangedField('city') 
                || $eventArgs->hasChangedField('state') || $eventArgs->hasChangedField('zip')){
                $this->geocodeApartment($apartment);
                
                $em = $eventArgs->getEntityManager();
                $uow = $em->getUnitOfWork();
                $meta = $em->getClassMetadata(get_class($apartment));
                $uow->recomputeSingleEntityChangeSet($meta, $apartment);
            }
        }
    }
    
    /**
     * Geocode and set the Apartment's latitude and longitude
     * 
     * @param type $apartment 
     */
    private function geocodeApartment($apartment){
        $result = $this->geocoder->getGeocodedData($apartment->getAddress());
        $apartment->latitude = $result['latitude'];
        $apartment->longitude = $result['longitude'];
    }
    
}
# app/config/config.yml
services:
  # ...

  geocoder.listener:
    class:  MS\RentrBundle\Doctrine\Listener\GeocoderEventSubscriber
    arguments: [@geocoder.address]
    tags:
      - { name: doctrine.event_subscriber }
The event subscriber is a little more verbose then I would like and you could abstract out the entity to make it reusable for multiple entity types but you would need to allow for configuration which could be accomplished with the service container. The main things to notice it that the service container passes in our configured geocoder instance, prePersist() handles the insert and preUpdate() handles all updates to the entity. To get our new subscriber working with Doctrine2 all we need to do is give it the tag "doctrine.event_subscriber". That's it now we have a full functioning geocoding solution that is simple and elegant.