Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Livewire Mapcn Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. 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.
  2. The skills/livewire-mapcn-development/SKILL.md file — it's effectively the usage manual and is more current than the README for component props.
  3. The Blade component sources — the package is small, single-maintainer, and you should know what you depend on.
  4. The v2.0.0 release notes if you're coming from v1 (see upgrade gate in Gotchas).

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.

Implementation Patterns

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.

  • Alpine ships with Livewire; you get x-data, $map, and the animation patterns with no extra dependency.
  • Nothing to do for 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.
  • Use <x-slot:loading> plus the loader prop for the lazy-load gap; a map that pops in unlabelled reads as broken.
  • OSRM directions are cached for the session and time out after 10s. Design an explicit loading state for :fetch-directions="true" routes — a spinner, not a blank map.

Gotchas and Tips

[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.

  1. @livewireMapStyles in head and @livewireMapScripts after @livewireScripts? Both, correct order.
  2. window.maplibregl defined? If not, it's the ESM/cdn_url problem.
  3. Is the container actually sized? Zero-height divs render nothing.
  4. Is the map below the fold and lazy? Scroll it into view or set :lazy="false".
  5. Coordinates in [lng, lat]? Check whether you're looking at the right ocean.
  6. Did you wire:ignore the map? Remove it.
  7. Events not firing? The event must be in :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."

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle