How we cut our Google Maps bill by 90% with a 60-line Laravel proxy
November 4, 2026
We built an address management package for Filament. It has a map preview - a small map that updates as you geocode an address, so the user can confirm the pin landed in the right place.
Simple feature. Expensive by default.
The problem
The obvious implementation is the Google Maps JavaScript API: drop in the script tag, render a Map component, drop a marker. It works. It also costs $7 per 1,000 map loads.
That sounds cheap until you think about where this component lives. An address resource in an admin panel loads on every create/show/edit, every record a staff member opens. Take as an example 5,000 addresses each viewed an average of 3 times per month - not an unusual number for a mid-size operation - and you're at 15,000 map loads:
15,000 × $0.007 = $105/month. For a preview map nobody is panning or zooming.
We asked a simple question: Does this map need to be interactive at all?
For an address preview, the answer is no. The user just needs to see where the pin landed, at a zoom level suitable for navigating the immediate area, with street names. A static image is fine, with a small overlay button for popping a new window with the free live map page.
The Static Maps API
Google offers a Static Maps API that returns a plain image for a given latitude, longitude, zoom, and size. The pricing is $2 per 1,000 requests - already 3.5× cheaper than the JavaScript API. But the real saving isn't the per-request price. It's that you can cache the result.
With the JavaScript API, every page load is a billing event. With a static image that you fetch once and store, only the first view of each unique location costs anything.
Same scenario - 5,000 addresses, 3 views each - but now repeat views hit your cache instead of Google:
5,000 × $0.002 = $10/month.
| Dynamic Maps JS | Static Maps + proxy | |
|---|---|---|
| API price | $7 / 1,000 loads | $2 / 1,000 requests |
| Billing event | Every page view | First view per location |
| 5k addresses × 3 views | $105/month | $10/month |
The proxy
We can't call the Static Maps API directly from the browser - the API key would be exposed in the request URL. So we route the image request through a Laravel controller that holds the key server-side.
// routes/map-image.php
Route::get('/map-image', MapImageController::class);
<!-- In the Blade/Alpine component -->
<img :src="`/map-image?lat=${lat}&lng=${lng}`" />
The controller does three things: validate the coordinates, check the cache, and fetch from Google only on a miss.
public function __invoke(Request $request): Response
{
$lat = round((float) $request->input('lat'), 4);
$lng = round((float) $request->input('lng'), 4);
// 4 decimal places ≈ 11m precision - good enough to share a cache key
// for addresses that geocode to nearly identical coordinates
$zoom = 15;
$size = '640x400';
$scale = 2;
$format = 'jpg';
$cacheKey = md5("{$lat},{$lng},{$zoom},{$size},{$scale},{$format}");
$path = "map-cache/{$cacheKey}.jpg";
$ttl = now()->subDays(90)->timestamp;
if (Storage::exists($path) && Storage::lastModified($path) > $ttl) {
return $this->imageResponse(Storage::get($path));
}
$queryString = http_build_query([
'center' => "{$lat},{$lng}",
'zoom' => $zoom,
'size' => $size,
'scale' => $scale,
'format' => $format,
'key' => config('addresses.google_maps_server_key'),
]);
// The Static Maps API uses | as a delimiter within parameter values (e.g. "color:red|lat,lng").
// http_build_query would percent-encode | to %7C - Google accepts both, but some reverse
// proxies between your app and Google's servers handle the encoded form inconsistently.
// Appending the marker string manually keeps the | pre-encoded as %7C and bypasses
// http_build_query entirely for this parameter, so we control exactly what goes on the wire.
$url = "https://maps.googleapis.com/maps/api/staticmap?{$queryString}"
. "&markers=color:red%7C{$lat},{$lng}";
$response = Http::timeout(10)->get($url);
if (! $response->successful()) {
return response('Failed to fetch map image', 502);
}
Storage::put($path, $response->body());
return $this->imageResponse($response->body());
}
private function imageResponse(string $body): Response
{
$response = new Response($body, 200, ['Content-Type' => 'image/jpeg']);
$response->setPublic();
$response->setMaxAge(86400); // browser cache: 1 day
return $response;
}
A few details worth noting:
Coordinate rounding. We round to 4 decimal places before building the cache key. That's about 11 metres of precision - fine for a map preview. Without rounding, geocoders that return slightly different float values for the same location would each get their own cache entry.
Browser cache headers. setPublic() + setMaxAge(86400) tells the browser to cache the image for a day. Repeat visits to the same form don't hit your server at all.
TTL check via lastModified. We use Storage::lastModified() rather than a separate cache record so there's no metadata to get out of sync. If the file is older than 90 days it's refetched - map tiles change occasionally.
Markers. http_build_query encodes | as %7C, which Google accepts - but we append the marker separately to keep the URL readable and avoid any proxy-layer issues with encoded pipes.
Input validation. The production version validates coordinate bounds (-90 ≤ lat ≤ 90, -180 ≤ lng ≤ 180), size format (must match \d+x\d+), zoom range (0–21), and maximum image dimensions before touching the API. Worth adding before exposing this route publicly.
Visibility: audit logging
Caching introduces a blind spot: without instrumentation, you don't know how often requests are actually reaching Google. With every request passing through your server, you can log each cache hit and miss at negligible cost:
DB::table('address_api_audit_logs')->insert([
'operation_type' => 'static_map',
'provider' => 'google',
'cache_hit' => $cacheHit,
'was_successful' => true,
'response_time_ms' => $responseTimeMs,
'created_at' => now(),
]);
After a week of real traffic you can see exactly how many requests hit Google versus the cache, and what your effective cost rate is. In our testing, cache hit rates run above 95% after the first few days - the initial cold-start fetch settles quickly.
What you give up
The interactive map is gone. For an address preview, that's the right trade. If your use case needs panning, zooming, or click-to-place interaction, the Static Maps API isn't a substitute - the JavaScript API is the right tool.
It's also worth knowing that Google's Terms of Service prohibit caching Static Maps responses for more than 30 days in some interpretations. We use a 90-day TTL in our implementation; check the current terms against your own comfort level and adjust accordingly.
The result
A 60-line controller, one route, and Storage::put(). The API key stays on the server. The browser gets a plain <img> tag. Each unique address is fetched once and cached - repeat views cost nothing.
$105/month → $10/month for the scenario above. The gap widens as address reuse goes up.
This proxy is included in Filament Address Pro, a package for international address management in Filament. But the approach works anywhere you'd otherwise load the Maps JavaScript API for a non-interactive preview - no package required.