kwasii/livewire-mapcn
A Livewire component for embedding interactive MapCN maps in Laravel apps. Drop a map into any Blade view, bind center coordinates, zoom, and markers to Livewire properties, and let the map update reactively as state changes—no manual JavaScript wiring required.
Install and wire up the layout (5 minutes).
composer require kwasii/livewire-mapcn
You do not need Node, npm, or Tailwind (v2 dropped that requirement). Assets are served through package routes with content-hashed cache headers.
Add the two directives to your Blade layout — @livewireMapStyles in <head> (it also preconnects to tile hosts), @livewireMapScripts after @livewireScripts:
<head>
@livewireMapStyles
</head>
<body>
@livewireScripts
@livewireMapScripts
</body>
Optionally publish config (--tag=livewire-mapcn-config) or assets (--tag=livewire-mapcn-assets). Publishing is only needed if you want to self-host, change the default provider, or swap out the CDN build.
Verify the platform floor first. PHP 8.3+, Laravel 11–13, Livewire 3 or 4. If you're on PHP 8.2 or Laravel 10, stop here — v2 is a hard gate.
Where to look first, in order:
config/livewire-mapcn.php — the twelve or so keys that govern everything (default_provider, osrm_url, load_from_cdn, maplibre_version, custom_events, cluster_popup_view). Read this before writing markup; half the "why doesn't it look like the demo" questions are answered here.skills/livewire-mapcn-development/SKILL.md file — it's effectively the usage manual and is more current than the README for component props.Your first use case: a store locator from Livewire state.
{{-- resources/views/livewire/store-locator.blade.php --}}
<x-map :center="[$this->lng, $this->lat]" :zoom="11" height="500px">
<x-map-controls position="top-right" :locate="true" :fullscreen="true" />
@foreach ($stores as $store)
<x-map-marker :lat="$store['lat']" :lng="$store['lng']" :id="$store['id']" wire:key="store-{{ $store['id'] }}">
<x-marker-label :text="$store['name']" position="top" />
<x-marker-popup>
<strong>{{ $store['name'] }}</strong>
<p>{{ $store['address'] }}</p>
</x-marker-popup>
</x-map-marker>
@endforeach
</x-map>
class StoreLocator extends Component
{
public float $lat = 51.505;
public float $lng = -0.09;
public function with(): array
{
return ['stores' => Store::query()->get()->map(fn ($s) => [
'id' => $s->id, 'lat' => $s->lat, 'lng' => $s->lng,
'name' => $s->name, 'address' => $s->address,
])->all()];
}
}
Change $this->lat/$this->lng from a filter or a search and the camera follows — no JavaScript. That single loop is the 80% case for internal tools, and it's the fastest way to prove the package works in your app before committing to anything heavier.
Livewire state drives the map, in place. Components inside <x-map> update without a full re-render, and the camera is preserved (the map uses wire:ignore.self internally). You get a reactive contract for free: Eloquent query changes → markers/routes/polygons redraw. Always wire:key inside loops so Livewire can diff correctly; without it, markers get shuffled instead of moved.
Dashboards: target one map, not all of them. A page with a map per row is now a viable design (maps are lazy and only instantiate near the viewport, and commands issued before start are queued). But you must address a specific map. Every command accepts a mapId:
$this->dispatch("map:ease-to", mapId: 'site-42', center: [$lng, $lat], zoom: 15);
$this->dispatch('map:set-theme', mapId: 'site-42', theme: 'dark');
Without mapId the command fans out to every map on the page. That's the single most common dashboard bug.
Cluster layers for dense point sets. Never render 2,000 <x-map-marker> tags. Use the cluster layer with the array shape the package expects:
<x-map-cluster-layer
:data="$locations"
cluster-color="#3b82f6"
:cluster-max-zoom="14"
:cluster-radius="50"
:show-count="true"
>
<x-slot:popup>
<h3 class="font-semibold text-sm">{name}</h3>
<p class="text-xs text-gray-500">{address}</p>
</x-slot:popup>
</x-map-cluster-layer>
$locations = [
['lat' => 51.505, 'lng' => -0.09, 'properties' => ['name' => 'Office A', 'address' => '10 Downing St']],
];
In the popup slot, {propertyName} placeholders interpolate from properties; {lat} and {lng} are always available. For high-frequency filtering, skip the re-render entirely and push GeoJSON from PHP:
$this->dispatch("map:update-cluster-data-{$this->clusterId}", GeoJSON::fromArray($filtered));
Route progress playback — the client-side animation trick. This is the pattern worth designing a feature around. Bind progress to an Alpine property name instead of a Livewire property and a scrubber/playback slider makes zero server requests:
<div x-data="{ travelled: 0 }">
<input type="range" min="0" max="1" step="0.01" x-model.number="travelled" class="w-full">
<x-map :center="[$lng, $lat]" :zoom="12" height="500px">
<x-map-route
id="delivery"
:coordinates="$routeCoords"
:fetch-directions="true"
directions-profile="driving"
progress-model="travelled"
color="#1A56DB"
:width="4"
>
<x-map-route-marker at="progress" :rotate="true" />
<x-map-route-marker at="start" />
<x-map-route-marker at="end" />
</x-map-route>
</x-map>
</div>
<x-map-route-marker at="0.5"> pins at a fraction; at="progress" follows the travelled line; :rotate="true" orients it to the heading. For server-driven progress, use :progress="$delivery->fraction" and dispatch map:route-progress-{id} instead.
Route options with a selection panel. When the user should pick between alternatives, don't hand-roll radio buttons that re-render the map:
<x-map :center="$center" :zoom="12">
<x-map-route-group>
<x-map-route id="fastest" :coordinates="$fastest" :fetch-directions="true" :alternatives="true" />
<x-map-route id="scenic" :coordinates="$scenic" :fetch-directions="true" />
</x-map-route-group>
</x-map>
Then $this->dispatch("map:select-route-{id}", 'scenic') from anywhere — a table row, a button, a dropdown. The panel components (<x-map-route-list>) exist if you want the overlay UI out of the box.
GeoJSON overlays for territory and coverage. Polygons, lines and points with fill/line/circle paints, hover states, tooltips, popups and fit-bounds:
<x-map blank :bounds="$serviceAreaBounds" height="600px">
<x-map-geojson
:data="$coverageGeoJson"
fill-color="#3b82f6"
:fill-opacity="0.25"
fill-hover-color="#2563eb"
line-color="#1e40af"
:fit-bounds="true"
>
<x-slot:popup>
<p>{postcode} — {orders} orders</p>
</x-slot:popup>
</x-map-geojson>
</x-map>
<x-map blank> (or provider="blank") renders no basemap at all. For an internal coverage analytics view this removes the tile-licensing conversation from the project entirely. Push updates with map:update-geojson-{id}.
Arcs for network and relationship views. <x-map-arc> draws curved connections with hover paint, tooltips, popups and click/hover events, and pairs with projection="globe":
<x-map projection="globe" :center="[10, 30]" :zoom="1.8" :pitch="30" height="600px">
@foreach ($lanes as $lane)
<x-map-arc
:from="[$lane['from_lng'], $lane['from_lat']]"
:to="[$lane['to_lng'], $lane['to_lat']]"
:id="$lane['id']"
wire:key="lane-{{ $lane['id'] }}"
hover-color="#f59e0b"
>
<x-marker-tooltip :text="$lane['label']" />
</x-map-arc>
@endforeach
</x-map>
This is the cheapest way to produce a genuinely impressive demo asset — trade lanes, partner networks, inter-facility flows — and it's a component, not custom code.
Bidirectional events: map → Livewire. Opt extra events in per map with the events prop, then handle them with #[On]:
<x-map :center="$center" :zoom="13" :events="['marker:drag-end']">
<x-map-marker :lat="$lat" :lng="$lng" :draggable="true" id="pin" />
</x-map>
#[On('map:marker-drag-end')]
public function onDragEnd(string $id, float $lat, float $lng): void
{
$this->lat = $lat;
$this->lng = $lng;
// persist, re-geocode, recompute radius, whatever
}
Click-to-drop-a-marker is the canonical version of this: listen for map:click, write lat/lng to a Livewire property, render a marker from it. Note that map:click also requires declaring the event in :events (or globally in custom_events).
Client-side work stays client-side. Alpine magics $map and $mapRoute('id') expose the underlying MapLibre instance and route object inside the map's scope. Anything that doesn't need the server — a 3D pitch toggle, fly-to on hover, camera smoothing — should use those rather than a Livewire roundtrip. Reserve dispatch for things that change persisted state.
Wrap the package in your own Blade partials. This is the pattern that makes the adoption safe. Build <x-ui-map>, <x-ui-map-marker>, <x-ui-map-route> as thin wrappers, and reference only your wrappers throughout the app. The package API is Blade over MapLibre primitives, so the exit to raw MapLibre becomes a contained rewrite of a handful of files rather than a re-architecture — but only if the package names appear in one directory.
Integration tips.
x-data, $map, and the animation patterns with no extra dependency.wire:navigate in v2 — WebGL contexts are torn down properly. If you're on v1, this is a real memory leak that grows with every navigation.@livewireMapStyles preloads MapLibre and preconnects to tile hosts. Put it in the layout head even on pages without maps if map pages are reachable from them.<x-slot:loading> plus the loader prop for the lazy-load gap; a map that pops in unlabelled reads as broken.:fetch-directions="true" routes — a spinner, not a blank map.[lng, lat] — longitude first. Every array coordinate in this package follows MapLibre's convention, which is inverted from the intuitive [lat, lng]. This applies to :center, :coordinates, :bounds, :from/:to on arcs, and GeoJSON. Note the asymmetry that causes confusion: individual marker attributes are separate (:lat="51.5" :lng="-0.09") and therefore safe, but the moment you pass an array — including $store['lat'] typed into a :center — order flips. The failure mode is silent: pins land in the ocean, nothing warns. Add a test or a helper (lngLat($model)) and use it everywhere.
Don't wrap <x-map> in wire:ignore. v1 users did this out of habit; in v2 it breaks the reactivity you want, because the package handles preservation itself via wire:ignore.self. If you've upgraded and markers stopped updating, this is almost certainly why. Grep for it.
Maps are lazy by default. They instantiate only when near the viewport. That's usually what you want, but it means a map in a collapsed accordion or an off-screen tab doesn't exist yet — commands fired at it are queued, which is fine for update-* but surprising when you expect an immediate visual response. Opt out with :lazy="false" when the map must be live immediately, or force it with a tab-change handler.
MapLibre v6 is ESM-only. If you published the config, cdn_url and cdn_css_url default to null and the package derives the right URLs. If you set cdn_url to a classic .js bundle, it will fail to load — point it at a .mjs file, or deliberately pin a v5 UMD bundle to stay on the older renderer. Symptom: empty container, window.maplibregl is undefined in the console.
Publishing views means owning the upgrade. If you ran vendor:publish --tag=livewire-mapcn-views, you must re-publish with --force on every major upgrade, or you'll be running v1 markup against v2 scripts. If you don't need to change the internals, don't publish them.
Pin the version. Single maintainer, no dependents, 25 stars. composer require kwasii/livewire-mapcn:^2.0 and commit the lockfile. The reactive contract (wire:key in loops, command naming like map:update-arc-data-{id}, mapId targeting) is the thing most likely to break quietly on a minor release — write one feature test asserting your map data shapes survive.
Cluster popups now escape feature properties — treat that as fixed, not as permission. v1 had an injection vector on maps showing user-supplied strings. On v2 it's escaped, but if you render map labels or popup slots with {!! !!} or custom Blade, you're back to owning the escaping yourself. Audit anything that renders a tenant- or customer-controlled name.
Licensing and infrastructure are your problem, not the package's. The defaults lean on CARTO basemaps (config flag reads non-commercial), a CDN-loaded MapLibre, and the public OSRM demo router. For anything customer-facing: publish assets and set inject_assets to 'published', switch to openfreemap-liberty / openfreemap-bright / openfreemap-positron (open, key-free, liberty includes 3D buildings), point osrm_url at your own routing service, and have the tile licence reviewed. For polygon-only internal views, provider="blank" sidesteps it all.
OSRM is not a routing SLA. Ten-second timeout, session-cached responses. A user retrying the same route gets the cache; a user on a flaky connection gets a 10s wait then nothing. Set :fetch-directions="false" and pass straight coordinates where a straight line is acceptable, and always render a loading state.
height="full" means 100% in v2, not viewport height. The parent must have a resolvable height, or the map collapses to zero pixels. This changed in v2 and broke layouts on upgrade. Give the wrapper an explicit height in most cases (height="500px"), and use full only inside a sized flex/grid cell.
Command naming is templated, and easy to typo. The pattern is map:update-geojson-{id}, map:route-progress-{id}, map:select-route-{id}, map:update-arc-data-{id}, map:update-cluster-data-{id}. Note that these embed the component id in the event name, while other commands take mapId as an argument. Two mechanisms; keep them straight. A dispatch that nobody listens for fails silently.
Theme changes used to drop layers. Fixed in v2 (routes, clusters and other layers now survive a map:set-theme), but it's a good regression to check when you upgrade — a dark-mode toggle is exactly the kind of thing nobody tests and every user touches.
Debugging checklist when a map is blank or inert.
@livewireMapStyles in head and @livewireMapScripts after @livewireScripts? Both, correct order.window.maplibregl defined? If not, it's the ESM/cdn_url problem.:lazy="false".[lng, lat]? Check whether you're looking at the right ocean.wire:ignore the map? Remove it.:events on that map or in custom_events in config — the package does not forward everything by default.Extension points worth knowing. custom_events in config registers global MapLibre events forwarded as map:* (useful for moveend/zoomend analytics across the app). cluster_popup_view points at a Blade view that becomes the default cluster popup with {property} placeholders — one file, applied everywhere, instead of repeating <x-slot:popup> on every layer. options on <x-map> passes straight through to the MapLibre constructor for anything the props don't cover, and it's the escape hatch that keeps you from forking.
wire:key in loops is not optional, and neither is stable identity. Use the database id, not $loop->index. With index keys, deleting the third of five markers makes the map animate five markers instead of removing one — and the popup contents end up attached to the wrong pins, which is the sort of bug that gets reported as "the map is lying."
How can I help you explore Laravel packages today?