Let’s say you’re building a mobile app in Silex (a PHP microframework), and you want to add location-based search to your features. You’ve got a data set with a bunch of street addresses, like this CSV that lists all facilities in British Columbia that are licensed to sell or produce liquor.

A quick example:

Name Address City Joe's Beer Shack 125 Nowhere Lane Victoria The Whinery 420 Toker Street Esquimalt Spinnakers 308 Catherine Street Victoria

Not that I’m thinking of creating a “nearest booze” app. I’ve got better things on my mind. Anyway, that’s off topic. Uh oh! Now I’ve decided to make the app.

I’m going to call it DrinkyDrinkyThing.

With this demo data set, you’ll notice that there are no latitude/longitude coordinates. Turns out those are pretty useful for pinpointing locations - but people don’t just hand them to you on a silver platter. Or DO they?

The Shadow Knows

You can give your app superpowers by adding "geocoder-php/geocoder-service-provider” (target “@stable”) to your composer.json.

OK, there are a few more steps than that. And the superpowers are notably less impressive than a low-level baddie on Agents of SHIELD.

You have to decide on a provider to use (there are many choices, but we’ll use OpenStreetMapProvider for this example), and you have to pay attention to each provider’s rules and regulations. Some of them will require an API key, others (such as OSM) will limit you to 1 request per second or something similar.

Here’s a quick run-through of what I’ve added to my Silex app’s bootstrap:

$app->register(new \Geocoder\Provider\GeocoderServiceProvider()); // we configure our provider here $app['geocoder.provider'] = $app->share(function () use ($app) { return new \Geocoder\Provider\OpenStreetMapProvider($app['geocoder.adapter']); });

Here’s a snippet of code for obtaining coordinates:

$geocoder = $app['geocoder’]; $result = $geocoder->geocode('308 Catherine Street, Victoria, BC, Canada'); echo "Latitude: " . $result->getLatitude() . "

"; echo "Longitude: " . $result->getLongitude() . "

";

Note the format I've passed the address in - it's a simple street address which doesn't include a unit number or any extra information. OpenStreetMapProvider can be rather unforgiving when it comes to complex addresses. If you have an API key for Google or one of the other providers, you may have to do less pre-formatting of the address to get a solid geocode lookup.

It's also worth noting that you should have the cURL extension enabled on your PHP installation. I spent several hours running a console task only to realize the next morning that nothing had actually been retrieved. If I had used one of the other available geocoder.adapter settings, I might have been able to get it working. But clearly, I should have added more console output to observe what was happening. :)

Your Own Private Treadstone

The next step is to obtain a latitude/longitude to search near. Many phones have geolocation built-in these days, so it’s quick to plunk in some javascript to get this info:

if (navigator.geolocation) { navigator.geolocation.watchPosition( function (pos) { console.log( 'Latitude: ' + pos.coords.latitude + ', ' + 'Longitude: ' + pos.coords.longitude ); }); }

I ended up embellishing my javascript implementation beyond the demo code above, because I wanted it to have some nicer usability (such as preventing constant server polling if you haven't moved very far, and detecting if the lookup failed or if geolocation was disabled in the browser). You can see this in the lookup.js file in the repository, linked below.

While doing the testing for that, I observed that several desktop browsers would actually return valid geolocation coordinates - but only if I didn't specify an error handler as the second parameter to watchPosition . It was rather useful for testing while it worked, but as soon as I added the error handler, the desktop clients refused to even try to look up coordinates. The desktop lookups had been off by several KMs, of course, but they at least were somewhat accurate. They weren't pinpointing me as lost in the middle of the Pacific.

Sending Out An S.O.S.

And the last brick in this PHPanopticon is the SQL query to calculate the distance and filter the results to be within a certain radius. Because the Earth is not a flat surface, we can't do a simple spatial lookup - the distance provided would be inaccurate. We have to run a special calculation called the Haversine formula that factors in the radius of the planet to determine the "great-circle distance" between two points:

SELECT *, ( 6371 * acos( cos(radians(:lat)) * cos(radians(latitude)) * cos(radians(longitude)-radians(:long)) + sin(radians(:lat)) * sin(radians(latitude)) ) ) AS distance FROM places HAVING distance < :radius ORDER BY distance LIMIT 0, 20

Note the hard-coded value of 6371 ; this is an approximation of the radius of Earth in KMs (which can vary), and this means the result will be calculated in kilometers. If we wanted the result in miles, we would use 3959 (roughly).

We store this in $query in PHP and pass it to Doctrine with an array of parameters to extract the 20 closest places:

// let's do this $result = $app['db']->executeQuery( $query, array( 'lat' => $lat, 'long' => $long, 'radius' => $radius, // eg: 5, for limiting results to a 5KM radius ) ); // we like arrays, although we'd prefer a simpler syntax for this $locations = $result->fetchAll(\PDO::FETCH_ASSOC); return new JsonResponse($locations);

I'm running a live demo at drinkydrinky.grubthing.com, but unless you're in the Vancouver/Victoria area, you probably won't see any results.

You're welcome to clone the repo and enter in a few of your local establishments (or add PRs for new data sources) to test things out.