nucleos/maps-bundle
Symfony bundle for adding interactive maps to your application. Configure map providers, render maps in Twig, and place markers and locations, keeping map setup centralized so pages can display geographic data with minimal effort and configuration.
Install and register
composer require nucleos/maps-bundle
If you don't have the Flex recipe, register the bundle in config/bundles.php:
Nucleos\MapsBundle\NucleosMapsBundle::class => ['all' => true],
Where to look first
src/DependencyInjection/Configuration.php — the config tree tells you every knob the bundle exposes (default provider, tile layer, zoom, attribution).src/Twig/ — the Twig extension is where the public rendering API lives. This is your real "documentation" since the README is thin.src/Resources/views/ — the Leaflet templates the bundle ships. Copy them into templates/bundles/NucleosMapsBundle/ to override.First use case
The typical entry point is rendering a simple map inside a Twig template. After installing, you usually just need to include the Leaflet assets once in your base layout and then call the bundle's render helper with a location and zoom:
{# base.html.twig #}
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
{# anywhere #}
{{ nucleos_maps_render(latitude: 52.52, longitude: 13.405, zoom: 13) }}
If your template doesn't recognize the function, run bin/console debug:twig and grep for map — the exact function name is the fastest thing to confirm before writing anything else.
Build the map in the controller, render in the template
Treat the bundle's rendered HTML as a dumb view. Resolve coordinates, marker metadata, and popup content in a controller (or a small MapViewFactory service) and pass a plain array/DTO to Twig. This keeps the template clean and makes the map testable without a browser.
#[Route('/store/{id}', name: 'store_show')]
public function show(Store $store): Response
{
return $this->render('store/show.html.twig', [
'map' => [
'lat' => $store->getLatitude(),
'lng' => $store->getLongitude(),
'zoom' => 15,
'popup' => $store->getName(),
],
]);
}
Geocoding addresses to coordinates
If your coordinates come from user-entered addresses rather than database columns, the bundle integrates with willdurand/geocoder-bundle (and as of 3.7.0, supports geocoder-bundle 7). Geocode server-side, then feed the resulting lat/lng into the render call — never geocode from the client on page load (rate limits, API keys in the open, slow first paint).
final class GeocodedMapViewFactory
{
public function __construct(private GeocoderInterface $geocoder) {}
public function fromAddress(string $address, int $zoom = 15): array
{
$location = $this->geocoder->geocodeQuery($address);
$coordinates = $location->first()->getCoordinates();
return [
'lat' => $coordinates->getLatitude(),
'lng' => $coordinates->getLongitude(),
'zoom' => $zoom,
'popup' => $address,
];
}
}
Cache geocoder lookups (a CacheItemPoolInterface decorated over the geocoder works well) — address normalization rarely changes and provider quotas are the first thing you'll hit in production.
Multiple markers from a collection
For "find stores near me," hydrate a marker list server-side and serialize it with json_encode(..., JSON_THROW_ON_ERROR). Pass it through |raw into a data- attribute or a small inline <script> that boots Leaflet. Don't loop render calls — one map, many markers.
Configuration-driven defaults
Override bundle defaults once in config/packages/nucleos_maps.yaml so every map in the app gets the same tile provider, attribution, and default zoom. Per-page overrides go through the render call, not the config. That split keeps staging and prod pointed at the same (or intentionally different) tile servers.
Integration with forms
For address-picking, wrap a text input in a small Stimulus controller (or plain JS) that reads the bundle's rendered map element, listens for clicks, and writes back lat/lng hidden inputs. The bundle renders the map; you own the form wiring. Don't try to make the bundle do form binding — it's a renderer, not a form type. Pair this with the geocoder above on submit to normalize whatever the user typed.
Layering with custom JS
The rendered map is a normal Leaflet instance — grab it by the element ID and add your own layers, clustering, or GeoJSON on top. This is the expected extension point: bundle for the base map, your JS for anything beyond a pin.
Asset loading is on you
The bundle renders markup; it does not usually pull in Leaflet for you. If the map area is blank or throws L is not defined, you forgot the Leaflet CSS/JS includes in the layout (or they're loaded after your inline map script). Load Leaflet in <head> or defer your map bootstrapping to DOMContentLoaded.
Coordinate order flips
Leaflet uses (lat, lng); GeoJSON uses [lng, lat]; many APIs return {lat, lng} objects. This is the #1 source of "map is in the ocean" bugs. Pick one convention at your boundary (the controller) and convert immediately. Add a unit test that asserts a known city lands where you expect. The same trap exists with geocoder results — willdurand/geocoder returns a Coordinates object with getLatitude()/getLongitude(), so keep accessor calls explicit rather than destructuring by position.
Geocoder-bundle version compatibility
If you're already using willdurand/geocoder-bundle, note that 6.x and 7.x have different service IDs and interfaces — the maps bundle's 3.7.0 release is what makes 7.x work. Pin willdurand/geocoder-bundle in composer.json alongside nucleos/maps-bundle so a transitive bump doesn't silently break geocoder decoration. On geocoder-bundle 6, stay on maps-bundle 3.6.x.
CSP and inline scripts
If you have a Content-Security-Policy, the bundle's inline bootstrap script will be blocked and you'll see a silent empty map with a console CSP error. Either add a nonce to the rendered script, move bootstrapping to an external JS file fed by data- attributes, or loosen the policy for script-src. This one burns half a sprint if you don't check the console early.
Tile server etiquette and rate limits
Default OpenStreetMap tiles are fine for dev but violate OSM's usage policy at production volume. Point nucleos_maps at your own tile server or a paid provider (Mapbox, Stadia, Thunderforest) before launch, and make sure the attribution string matches the provider's terms. Cache the tile URL template; the config is loaded per request but that's cheap — the real cost is hammering the tile server.
Escaping user-controlled popup content
Popup HTML is injected into the DOM. If you pass a store name through popup and use |raw, you've opened an XSS vector. Escape with e('html') in Twig for anything user-supplied, or pass a plain string and let Leaflet's bindPopup treat it as text. Geocoded address strings are user-controllable too — escape those as well if they end up in a popup.
Debugging a blank map
Check in this order: (1) browser console for L is not defined / CSP errors; (2) debug:twig to confirm the function name; (3) debug:container for the map renderer service and its config; (4) that the container <div> actually has a height — Leaflet renders 0px tall inside a flex/grid parent that has no intrinsic height. That last one accounts for more "the bundle is broken" bug reports than anything else. If geocoding is in play, also log the provider response — a null Location from the geocoder results in a map at (0, 0) rather than an error.
Extension points
To change the generated markup, override the bundle templates under templates/bundles/NucleosMapsBundle/ rather than fork. For behavior, decorate the renderer service:
services:
App\Maps\DecoratingMapRenderer:
decorates: nucleos_maps.renderer
arguments: ['@.inner']
Confirm the service ID with bin/console debug:container | grep map — the exact ID is the one thing you'll want to verify against your installed version, since the namespace has evolved.
Version pinning
With a low star count and a small dependent base, pin the minor version (composer require nucleos/maps-bundle:^3.7) and read the CHANGELOG on upgrade. There's no huge ecosystem pressure to keep it stable, so test the rendered output after bumps — template, service-ID, and geocoder-bundle integration changes are the likely breakage points.
How can I help you explore Laravel packages today?