Skip to content

A Laravel OpenStreetMap map with no API key, drawn in PHP ​

You can put a Laravel OpenStreetMap map on a page with no API key and no map library: compute the zoom, the tiles and the pin positions in PHP, and emit them as plain <img> tags positioned in percentages. That is how the Locations section in FilamentCraft has drawn its map since v1.32.0. The map is correct before any JavaScript runs, never shifts layout, and a small script adds panning and zoom on top. This post covers the math, the one trick that makes it responsive without JavaScript, and what OpenStreetMap's tile policy asks of you.

Why not an embed or Leaflet ​

The Locations section already accepted an embed URL, and it still does. An embed shows one marker, and a store with five branches wants five numbered pins that match five cards under the map. The usual answer is Leaflet plus a tile layer, which is a good library, but it means shipping a map library to every visitor of a page builder's public site for one section. FilamentCraft keeps the public site free of JavaScript unless a section needs it, and a map that only exists after JavaScript runs also leaves an empty box during load.

So we asked a smaller question. A slippy map is a grid of 256 pixel square images at known URLs, /{z}/{x}/{y}.png. If the server knows the coordinates, it can work out which images to show and where. The browser only has to lay them out.

Web Mercator in a few lines of PHP ​

Every tile server in common use speaks Web Mercator. At zoom z the world is a square 256 * 2^z pixels wide, and a longitude and latitude map to a pixel like this. These are the private helpers in FilamentCraft\Sections\Support\MapView:

php
private static function lngToPixel(float $lng, int $zoom): float
{
    return ($lng + 180.0) / 360.0 * self::worldSize($zoom);
}

private static function latToPixel(float $lat, int $zoom): float
{
    $radians = max(-85.05112878, min(85.05112878, $lat)) * M_PI / 180.0;
    $mercator = log(tan($radians) + 1 / cos($radians));

    return (1 - $mercator / M_PI) / 2 * self::worldSize($zoom);
}

The latitude clamp is the edge of the projection. Web Mercator stretches toward infinity at the poles, so every tile set stops at about 85.05 degrees.

With those two functions and their inverses, MapView::fit() does the rest:

  1. Pick a zoom. With one location it uses zoom 15. With several it walks down from the provider's maximum zoom until the bounding box of all pins fits inside the canvas minus 64 pixels of padding on each side, so a pin at the edge is not clipped.
  2. Center on the middle of that bounding box.
  3. List every tile that overlaps the canvas, wrapping x around the date line and skipping y rows outside the world.
  4. Place each pin relative to the canvas origin.

An editor can also force a zoom level (world through building) in the section settings. A forced zoom can be far too deep for pins in different cities, and the midpoint between Berlin and San Francisco is open ocean. When the pins do not fit, fit() centers on the first pin so the map at least shows a real place.

Percentages instead of pixels ​

The server has no idea how wide the visitor's screen is. So the canvas is a nominal box, not a real one. The section's map_ratio setting picks it: wide is 800 by 450, standard 720 by 540, square 560 by 560 and tall 540 by 720. All tile and pin positions are computed in that box and then divided by its size:

php
new MapTile(
    $zoom,
    (($x % $span) + $span) % $span,
    $y,
    $x,
    self::round(($x * $size - $originX) / $width * 100),
    self::round(($y * $size - $originY) / $height * 100),
    self::round($size / $width * 100),
    self::round($size / $height * 100),
);

The Blade partial prints each tile as an <img> with left, top, width and height in percent, inside a container whose aspect-ratio matches the nominal box. The browser scales the whole grid uniformly to whatever width the column has. With JavaScript switched off the visitor still gets a finished map with numbered pins, and because the box has an aspect ratio from the first byte, nothing jumps when the tiles arrive.

The Add a section dialog in the FilamentCraft editor with a search box, a Create your own option and cards for Header, Hero, Logo cloud, Features, Image with text and Stats
Locations is one of the built-in sections in this catalog. An editor adds it here and pastes coordinates into each location block.

When the site bundle does run, it reads a small JSON config the server printed next to the map (zoom, center, pins, tile template, maximum zoom), measures the element, and re-tiles it at its real pixel width. After that it adds drag to pan, zoom buttons, double-click zoom and Ctrl or ⌘ plus scroll. A plain scroll wheel always scrolls the page. A map that swallows the scroll wheel traps the reader halfway down a long page.

Coordinates the way editors have them ​

Nobody types 48.8584 and 2.2945 into two fields. They copy something from a map. The lat and lng fields on each location block accept a pair, a Google Maps place link with @48.8584,2.2945, a Google share link with !3d…!4d…, an OpenStreetMap #map= fragment or mlat/mlon link, or a geo: URI, pasted into either field. GeoPoint::parse() finds the pair. A location with coordinates and no directions link gets one built from them.

What OpenStreetMap's tile policy asks ​

"No API key" does not mean "no rules". The OSM tile usage policy is short and worth reading in full. The parts that shaped the code:

  • A valid Referer header is required from web pages. Each tile <img> carries referrerpolicy="origin", so the tile server sees your site's origin even when the page's own policy would strip it.
  • Prefetching is treated as bulk downloading. The script only ever requests tiles for the current viewport, never the ring around it.
  • Attribution must be visible on the map, typically bottom right. It renders on every map from two config keys.
  • Availability is best effort with no SLA, and the policy warns that commercial access "may be withdrawn at any point".
  • The standard tiles stop at zoom 19, so max_zoom defaults to 19. A request for zoom 20 is an error, not a blank tile.

The last two points are why the tile source is config, not a constant. The defaults in config/filamentcraft.php point at OpenStreetMap so a map appears with zero setup. A busy storefront should switch to a provider with terms that fit, and it is one block:

php
'maps' => [
    'tile_url' => 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png',
    'attribution' => '© Stadia Maps © OpenMapTiles © OpenStreetMap',
    'attribution_url' => 'https://www.openstreetmap.org/copyright',
    'max_zoom' => 20,
],

The template understands {z}, {x}, {y}, {s} for subdomains and {r} for retina tiles. Each key also reads an environment variable (FILAMENTCRAFT_MAP_TILE_URL and friends), and a template that is not an http or https URL is ignored. Set tile_url to an empty string and tile maps turn off everywhere. Coordinates then fall back to OpenStreetMap's own embed, which is interactive but shows one marker.

Matching the site's colors ​

Raster tiles come in the provider's palette, which rarely matches a brand. The section's map_tone setting re-skins them in CSS instead of switching tile sets: natural leaves them alone, muted desaturates, tinted grays the tiles and blends a gradient of the color scheme's primary and accent over them, and night inverts them for dark schemes. map_source stays on auto by default, which prefers coordinates, then an embed URL, then a static image, so a site that only ever set an embed URL renders exactly as it did before v1.32.0.

The built-in sections guide lists every Locations setting and the embed URL allowlist, and custom sections shows how to build your own section if you want the map in a different layout. Every tier on the pricing page includes the same sections, and you can add a Locations section to a page in the live demo.

Last updated: