<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jobbri UK</title>
    <description>The latest articles on DEV Community by Jobbri UK (@jobbri_uk).</description>
    <link>https://dev.to/jobbri_uk</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4213134%2F2c64d312-16dd-47e8-98fb-6be0c29dfc80.jpg</url>
      <title>DEV Community: Jobbri UK</title>
      <link>https://dev.to/jobbri_uk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jobbri_uk"/>
    <language>en</language>
    <item>
      <title>How we replaced slow Overpass API queries with a 651k-row local UK geospatial engine in Laravel 12 &amp; PHP 8.4</title>
      <dc:creator>Jobbri UK</dc:creator>
      <pubDate>Wed, 28 Oct 2026 13:09:22 +0000</pubDate>
      <link>https://dev.to/jobbri_uk/how-we-replaced-slow-overpass-api-queries-with-a-651k-row-local-uk-geospatial-engine-in-laravel-12-4bg8</link>
      <guid>https://dev.to/jobbri_uk/how-we-replaced-slow-overpass-api-queries-with-a-651k-row-local-uk-geospatial-engine-in-laravel-12-4bg8</guid>
      <description>&lt;p&gt;When building &lt;strong&gt;&lt;a href="https://jobbri.co.uk" rel="noopener noreferrer"&gt;Jobbri UK&lt;/a&gt;&lt;/strong&gt; — a direct-hire UK recruitment and gig economy platform designed to connect candidates with employers without recruitment agencies — we wanted to solve a very practical problem for British job seekers: &lt;strong&gt;commute and relocation transparency&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Most job boards only show a city name like &lt;em&gt;"Manchester"&lt;/em&gt; or &lt;em&gt;"London"&lt;/em&gt;. Before applying for a role, candidates actually need to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How far is the office from the nearest train, tube, or bus station?&lt;/li&gt;
&lt;li&gt;Are there supermarkets, GP practices, gyms, and nurseries nearby?&lt;/li&gt;
&lt;li&gt;What percentage of the net take-home pay (in £ GBP) will go towards local rent in that specific UK postcode?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To answer this, we built &lt;strong&gt;Area Guide 2.0&lt;/strong&gt; directly into every live listing on our &lt;strong&gt;&lt;a href="https://jobbri.co.uk/jobs" rel="noopener noreferrer"&gt;UK jobs search engine&lt;/a&gt;&lt;/strong&gt; alongside our &lt;strong&gt;&lt;a href="https://jobbri.co.uk/career-tools" rel="noopener noreferrer"&gt;free UK salary calculator and career tools&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;However, our initial architecture hit a major bottleneck in production: &lt;strong&gt;public Overpass API rate limits and timeouts&lt;/strong&gt;. Here is how we re-engineered the entire pipeline using a local 651,779-object UK master database that resolves 10 categories of local amenities in &lt;strong&gt;under 15 milliseconds&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with live Overpass API queries in production
&lt;/h2&gt;

&lt;p&gt;Initially, whenever a new UK job was posted with a valid postcode (e.g., &lt;code&gt;M1 1AL&lt;/code&gt; or &lt;code&gt;SW1A 1BB&lt;/code&gt;), a background worker geocoded the postcode via &lt;code&gt;postcodes.io&lt;/code&gt; and queried the public OpenStreetMap Overpass API for nearby transport nodes, supermarkets, healthcare facilities, hotels, and restaurants.&lt;/p&gt;

&lt;p&gt;In production, this caused three critical issues:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;HTTP 429 &amp;amp; 504 Timeouts:&lt;/strong&gt; Public Overpass instances frequently throttle concurrent requests from cloud VPS ranges, leaving some job listings with empty neighbourhood tabs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rural vs. Urban Radius Mismatch:&lt;/strong&gt; A fixed &lt;code&gt;1,500m&lt;/code&gt; radius works well in Central London (&lt;code&gt;EC2N&lt;/code&gt;), where there are hundreds of tube stations and cafes. In smaller UK towns or industrial estates, the nearest hotel or railway station might be &lt;code&gt;4km&lt;/code&gt; to &lt;code&gt;10km&lt;/code&gt; away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency:&lt;/strong&gt; Each external Overpass HTTP call took between &lt;code&gt;2.5s&lt;/code&gt; and &lt;code&gt;8.0s&lt;/code&gt; to parse.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Step 1: Extracting 651,779 UK POIs with Geofabrik &amp;amp; C++ &lt;code&gt;osmium-tool&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Instead of querying an external API over the network, we decided to bring the entire United Kingdom OpenStreetMap dataset local to our Ubuntu VPS.&lt;/p&gt;

&lt;p&gt;We downloaded the official &lt;code&gt;united-kingdom-latest.osm.pbf&lt;/code&gt; archive (~1.9 GB compressed) from Geofabrik. Parsing a multi-gigabyte PBF file directly in PHP is inefficient, so we used the C++ &lt;strong&gt;&lt;code&gt;osmium-tool&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;osmium tags-filter&lt;/code&gt;) to filter only the relevant nodes and ways (transport stops, railway stations, supermarkets, restaurants, GP surgeries, pharmacies, gyms, parks, and hotels) and export them into a clean GeoJSON stream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;osmium tags-filter united-kingdom-latest.osm.pbf &lt;span class="se"&gt;\&lt;/span&gt;
  nwr/public_transport nwr/railway&lt;span class="o"&gt;=&lt;/span&gt;station,tram_stop &lt;span class="se"&gt;\&lt;/span&gt;
  nwr/highway&lt;span class="o"&gt;=&lt;/span&gt;bus_stop nwr/amenity&lt;span class="o"&gt;=&lt;/span&gt;restaurant,cafe,fast_food,pub,pharmacy,doctors,hospital,dentist,gym,cinema &lt;span class="se"&gt;\&lt;/span&gt;
  nwr/shop&lt;span class="o"&gt;=&lt;/span&gt;supermarket,convenience,mall &lt;span class="se"&gt;\&lt;/span&gt;
  nwr/tourism&lt;span class="o"&gt;=&lt;/span&gt;hotel,hostel,guest_house,attraction &lt;span class="se"&gt;\&lt;/span&gt;
  nwr/leisure&lt;span class="o"&gt;=&lt;/span&gt;park,fitness_centre &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; uk-filtered-amenities.osm.pbf &lt;span class="nt"&gt;-O&lt;/span&gt;

osmium &lt;span class="nb"&gt;export &lt;/span&gt;uk-filtered-amenities.osm.pbf &lt;span class="nt"&gt;-f&lt;/span&gt; geojsonseq &lt;span class="nt"&gt;-o&lt;/span&gt; uk-places.geojsonseq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reduced the raw planetary data into &lt;strong&gt;651,779 verified UK points of interest&lt;/strong&gt;, which our Laravel console command (&lt;code&gt;ImportUkMasterMap.php&lt;/code&gt;) streamed into a dedicated warehouse table (&lt;code&gt;uk_master_places&lt;/code&gt;) in MySQL 8.0 using batch inserts and a composite spatial bounding-box index on &lt;code&gt;(category, lat, lon)&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Two-tier table isolation (&lt;code&gt;uk_master_places&lt;/code&gt; vs &lt;code&gt;nearby_places&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;A common mistake when storing 650k+ geospatial rows in MySQL is running Haversine distance queries directly from the frontend Livewire component on every page view.&lt;/p&gt;

&lt;p&gt;To keep frontend page loads under &lt;code&gt;80ms&lt;/code&gt;, we separated storage into two tiers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;uk_master_places&lt;/code&gt; (651,779 rows):&lt;/strong&gt; The offline read-only UK warehouse indexed by &lt;code&gt;(category, lat, lon)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;nearby_places&lt;/code&gt; (Lightweight active cache):&lt;/strong&gt; Stores only the pre-computed top amenities for active job postcodes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When a job is published on &lt;strong&gt;&lt;a href="https://jobbri.co.uk" rel="noopener noreferrer"&gt;Jobbri UK&lt;/a&gt;&lt;/strong&gt;, our &lt;code&gt;AreaDataService&lt;/code&gt; runs an &lt;strong&gt;Adaptive Category Radius Algorithm&lt;/strong&gt; (&lt;code&gt;~2km&lt;/code&gt; -&amp;gt; &lt;code&gt;~6km&lt;/code&gt; -&amp;gt; &lt;code&gt;~13km&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;If a job is located in a rural postcode and the &lt;code&gt;2km&lt;/code&gt; bounding box returns zero hotels or railway stations, the query automatically expands the bounding box for that specific category up to &lt;code&gt;13km&lt;/code&gt; and calculates the exact Haversine distance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;syncFromUkMaster&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$postcode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$lon&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$categories&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'transport'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'food'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'supermarkets'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'hotels'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'healthcare'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'entertainment'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'landmarks'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nv"&gt;$radiiDegrees&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.055&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// ~2.2km, ~6km, ~13km&lt;/span&gt;

    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$categories&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$radiiDegrees&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'uk_master_places'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'category'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereBetween&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'lat'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$lat&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$lat&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$deg&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;whereBetween&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'lon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$lon&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$lon&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$deg&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$found&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Sort by exact Haversine distance and cache top 12 in nearby_places&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;whereBetween('lat', ...)&lt;/code&gt; and &lt;code&gt;whereBetween('lon', ...)&lt;/code&gt; hit the B-Tree composite index &lt;code&gt;(category, lat, lon)&lt;/code&gt; before computing trigonometric distances in PHP, syncing all 7 categories for a new UK postcode takes &lt;strong&gt;less than 12 milliseconds&lt;/strong&gt; with zero external HTTP calls!&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Combining geospatial data with 2026/27 UK take-home pay
&lt;/h2&gt;

&lt;p&gt;Once the local amenities and transport links are mapped on Leaflet.js with OSRM walking/cycling routes, we combine the job's advertised salary with our &lt;strong&gt;&lt;a href="https://jobbri.co.uk/career-tools" rel="noopener noreferrer"&gt;UK salary and tax calculator&lt;/a&gt;&lt;/strong&gt; (using the 2026/27 HMRC Personal Allowance of £12,570, Income Tax bands, and Class 1 National Insurance thresholds).&lt;/p&gt;

&lt;p&gt;This lets candidates see—on a single screen—their exact monthly net pay in &lt;strong&gt;£ GBP&lt;/strong&gt; alongside the average 1-bedroom and shared-room rent for that specific UK postcode sector.&lt;/p&gt;




&lt;h2&gt;
  
  
  Results in production
&lt;/h2&gt;

&lt;p&gt;By moving from live Overpass HTTP calls to our local 651k-row &lt;code&gt;uk_master_places&lt;/code&gt; engine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;0% API rate-limit failures:&lt;/strong&gt; Every single UK job listing gets 100% populated neighbourhood tabs, even in rural postcodes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sub-100ms TTFB:&lt;/strong&gt; Frontend job pages (&lt;code&gt;JobDetails.php&lt;/code&gt;) load pre-indexed records instantaneously.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;99/100 Google PageSpeed Desktop &amp;amp; Grade A+ On-Page SEO:&lt;/strong&gt; Fast server response times directly improved our Core Web Vitals and crawl budget across 1,700+ UK job listings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are building location-aware SaaS applications in Laravel and PHP 8.4, pre-filtering Geofabrik PBF extracts with &lt;code&gt;osmium-tool&lt;/code&gt; into an indexed local warehouse table is one of the cleanest architectural upgrades you can make.&lt;/p&gt;

&lt;p&gt;You can see the live postcode Area Guide and salary tools in action at &lt;strong&gt;&lt;a href="https://jobbri.co.uk" rel="noopener noreferrer"&gt;https://jobbri.co.uk&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
