Geography in Laravel: retrieving geographical data

Geography in Laravel: retrieving geographical data

After some time a new technical post and this time even the start of a series. Lately I started to work a lot on and with geographical data for a new project. In this series I try to cover all aspects I hit during that project that relate to geographical data. The beginning will be how to retrieve geographical data.

Disclaimer: I won't do any comparisons or anything special for these posts, only cover what I have done anyway.

Overpass API

Some of you probably know the OpenStreetMap project - part of it is the Overpass API which you can use to retrieve data. It behaves kind of similar to GraphQL as it only has one endpoint and you send a query to it using the Overpass QL. OpenStreetMap and general and so also the Overpass API has only 3 different entities:

  • Node: a specific point on the earth's surface
  • Way: a linear feature or boundary or an area
  • Relation: a combination of nodes and or ways

OSM Data Objects

We can create simple objects to hold these entities in our PHP code.

Node

php
class Node
{
    /**
     * @param  array<string, string>  $tags
     */
    public function __construct(
        public readonly int $id,
        public readonly float $lat,
        public readonly float $lon,
        public readonly array $tags = [],
    ) {
    }
}

Way

php
class Way
{
    /**
     * @param  array{lat: float, lon: float}  $center
     * @param  array<array-key, int>  $nodes
     * @param  array<string, string>  $tags
     */
    public function __construct(
        public readonly int $id,
        public readonly array $center,
        public readonly array $nodes,
        public readonly array $tags = [],
    ) {
    }
}

Relation

php
class Relation
{
    /**
     * @param  array{lat: float, lon: float}  $center
     * @param  array<array-key, array{type: string, ref: int, role: string}>  $members
     * @param  array<string, string>  $tags
     */
    public function __construct(
        public readonly int $id,
        public readonly array $center,
        public readonly array $members,
        public readonly array $tags = [],
    ) {
    }
}

If you want you can use a DTO package like spatie/laravel-data to get some automatic property assignement, casting etc functionality. These are also the plain objects - as you can see only holding the ID of child elements like the nodes in the way object. For sure you can also resolve the node objects first and assign the nodes directly.

Overpass query

First of all there is a cool sandbox/playground you can use to experiment with your Overpass query at overpass-turbo.eu. You can see the plain response or visualize it on a map - that helps a lot in building your query first or debugging if you don't get what you want.

I work with the following query for now as I'm only interested in a some POIs and buildings.

php
[out:json]
[timeout:30]
[bbox:{$box->south()},{$box->west()},{$box->north()},{$box->east()}];
(
  node[amenity];
  way[building];
  >;
);
out center;

A short explanation of each line:

  • [out:json] - the response should be JSON
  • [timeout:30] - the query timeouts after 30sec
  • [bbox:{$box->south()},{$box->west()},{$box->north()},{$box->east()}]; - define a bounding box for the whole query
  • node[amenity]; - select nodes with any amenity tag
  • way[building]; - select ways with any building tag
  • >; - select all nodes that are part of the building ways
  • out center; - add the center for each item

This won't find all buildings as some buildings are relation entities as they are more complex structures. Primary buildings with a whole (top-down perspective). But relations are way more complicated to handle so I ignore them for now.

Send the query and parse result

As we already have the DTOs and the query this is now a pretty simple process using the Laravel HTTP client.

php
<?php

namespace App\Actions;

use App\Concerns\Makeable;
use App\Data\Overpass\Node;
use App\Data\Overpass\Relation;
use App\Data\Overpass\Way;
use App\Geotools\Data\Box;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;

class FetchMapDataInBoundingBox
{
    /**
     * @return array{ node: Collection<array-key, Node>, way: Collection<array-key, Way>, relation: Collection<array-key, Relation> }
     *
     * @throws \Illuminate\Http\Client\ConnectionException
     */
    public function execute(Box $box): array
    {
        $response = Http::acceptJson()
            ->timeout(60)
            ->connectTimeout(10)
            ->throw()
            ->get('https://overpass-api.de/api/interpreter', [
                'data' => <<<TXT
                [out:json]
                [timeout:30]
                [bbox:{$box->south()},{$box->west()},{$box->north()},{$box->east()}];
                (
                  node[amenity];
                  way[building];
                  >;
                );
                out center;
                TXT
            ])
            ->collect('elements')
            ->groupBy('type');

        $nodes = collect($response->get('node'))
            ->map(fn (array $data) => Node::from($data))
            ->values();

        $ways = collect($response->get('way'))
            ->map(fn (array $data) => Way::from($data))
            ->values();

        $relations = collect($response->get('relation'))
            ->map(fn (array $data) => Relation::from($data))
            ->values();

        return [
            'node' => $nodes,
            'way' => $ways,
            'relation' => $relations,
        ];
    }
}

Some explanation of what this action does:

  • generate the query based on a Box object - you can use whichever box object you want or create your own one
  • send the query to https://overpass-api.de/api/interpreter and create a collection of elements
  • group all elements by their type
  • map all elements to their php objects
  • return an array of three collections holding the returned elements

Summary

With this approach you can query whichever geographical data you want/need. By adjusting the bounding box and the query itself you can change the returned data - depending on the size of your box you will probably have to increase the timeout. If you heavily depend on these data and need them quite often you should probably host your own Overpass API instance.

In the next post I will show you how I store these information in my local database using PostGIS.