afsakar/filament-leaflet-map-picker
Filament Leaflet Map Picker adds an interactive Leaflet map field to Filament forms, letting users click or drag a marker to select latitude and longitude coordinates. Configurable map options make location picking easy to integrate into Laravel admin panels.
Install the package and sync Filament's assets:
composer require afsakar/filament-leaflet-map-picker
php artisan filament:assets
That's it — there is no service provider registration step. The field is a standard Filament Field, so it drops into any form schema.
Where to look first. The repo is small (one field class, one Blade view, a handful of JS/CSS assets), so read three things in this order: the README for the chainable API, src/LeafletMapPicker.php for the actual method signatures and default options, and resources/views/ for how the Leaflet instance is bootstrapped. Ten minutes of source reading will tell you more than the docs, because the package is thin enough that every behavior is visible.
Storage. The field stores a coordinate pair as state. The idiomatic setup is one JSON column:
Schema::table('stores', function (Blueprint $table) {
$table->json('location')->nullable();
});
class Store extends Model
{
protected $casts = ['location' => 'array'];
}
use Afsakar\LeafletMapPicker\LeafletMapPicker;
public static function form(Form $form): Form
{
return $form->schema([
TextInput::make('name')->required(),
LeafletMapPicker::make('location')
->label('Store location')
->defaultLocation(41.0082, 28.9784) // Istanbul
->zoom(13)
->required(),
]);
}
First use case. Anything with an address you want to pinpoint without a paid geocoding key: store/restaurant locators, delivery zones, field-service job sites, event venues. The admin drags a pin, saves, and later you read $store->location['lat'] / ['lng'] to render a static map, feed a routing API, or embed a marker on a public page. Verify the exact key names by dd()ing the state after your first save — lat/lng is the convention here, but confirm against the installed version rather than trusting a blog post.
Single JSON column vs. two decimals. JSON is the path of least resistance and keeps the field's native state shape. Two decimal(10,7) columns are better when you need to query spatially (whereBetween('lat', ...)), index for "stores within a bounding box", or sync to a non-Laravel consumer. If you go the two-column route, keep the map field non-dehydrated and mirror the value in afterStateUpdated, or store the map state in a virtual field and copy it in mutateFormDataBeforeSave. Don't half-do it — a JSON column plus separate lat/lng columns is a guaranteed data-drift bug.
Defaults from an existing record. defaultLocation() is evaluated when the form is built, so read from the record or from config:
LeafletMapPicker::make('location')
->defaultLocation(
$this->record?->location['lat'] ?? config('app.default_lat'),
$this->record?->location['lng'] ?? config('app.default_lng'),
)
->zoom($this->record ? 16 : 6)
->height('360px');
Reusable field factory. If six resources need the same map, put it on a trait or a static factory method on LeafletMapPicker (subclass it) so the tile URL, height, and zoom live in one place. This is the easiest extension point in the whole package.
Read-only display in tables and infolists. Don't try to render an interactive map per table row. Store the coordinates, then show a link or a static tile thumbnail:
Tables\Columns\TextColumn::make('location')
->formatStateUsing(fn ($state) => is_array($state)
? "{$state['lat']}, {$state['lng']}"
: '—')
->url(fn ($record) => $record->location
? "https://www.openstreetmap.org/?mlat={$record->location['lat']}&mlon={$record->location['lng']}#map=17"
: null)
->openUrlInNewTab(),
For a visual, build the OSM static URL string yourself with img in an infolist ViewEntry — that avoids booting a full Leaflet instance 25 times on an index page.
Custom tiles. Pass your own tile URL when you need a brand-consistent basemap, dark mode, or higher tile quotas:
LeafletMapPicker::make('location')
->tilesUrl('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png');
Wire this to an env var (LEAFLET_TILES_URL) so staging and prod can use different providers without a deploy.
Wizards and modal actions. The field works inside Wizard steps and modal actions, but only if the map container is actually visible when the Leaflet instance initializes. If you hit a grey or half-rendered map, the usual fix is on the package's side already (deferred init), otherwise call map.invalidateSize() on wizard step change. If the package doesn't expose a hook for that, subclass it and add a small JS hook in the view — the view is a single file, so forking it is cheap.
Validation. Don't rely on required() alone for a map field. Add a coordinate sanity rule, because a misconfigured defaultLocation or a user who clicks the wrong hemisphere will happily pass required:
->rule(function () {
return function (string $attribute, $value, Closure $fail) {
if (! is_array($value) || ! isset($value['lat'], $value['lng'])) {
$fail('Pick a location on the map.');
return;
}
if ($value['lat'] < -90 || $value['lat'] > 90 || $value['lng'] < -180 || $value['lng'] > 180) {
$fail('Coordinates are out of range.');
}
// optional: restrict to a service area polygon/bbox
};
})
Testing. You cannot exercise Leaflet in a Pest test, and you shouldn't try. Test the contract: the field's state is an array with lat/lng after fillForm, it dehydrates into the column, and it round-trips through the cast. That covers 95% of real bugs; the remaining 5% are CSS/JS and require a browser.
Assets 404 after every upgrade. The single most common "the map is blank" cause. composer update doesn't move the package's JS/CSS into public/. Run php artisan filament:assets (and in CI, run it after composer install) — or, if the package registers publishable tags, php artisan vendor:publish --tag=filament-leaflet-map-picker-assets --force. A map that renders a grey box with a working zoom control usually means CSS loaded but tiles didn't; a completely dead box usually means the JS bundle is missing.
State shape is a real integration risk. If the field returns null after a user pans without dropping a pin, $record->location['lat'] throws. Guard reads with data_get($record->location, 'lat') or a locationLabel() accessor. Likewise, if you migrate from a string "lat,lng" format used by an earlier version or a different package, add a normalizing cast/mutator rather than patching every read site.
Tile provider terms of service. OpenStreetMap tiles are free but explicitly not for high-traffic production use — OSM's tile usage policy will block you. For anything beyond an internal admin panel, use MapTiler, Mapbox, Carto, or a self-hosted tile server, and route the URL through config. Also check whether your chosen provider's attribution requirement is preserved by the package view; stripping attribution is a licensing violation, not a styling choice.
Content Security Policy. If you serve Filament behind a CSP, Leaflet breaks silently unless you allow the tile host in img-src and, for some providers, connect-src. The symptom is a rendered map frame with empty tiles and a console full of CSP violations — no PHP error anywhere.
Dark mode and theme switching. The map won't invert itself when Filament's theme changes. Provide a dark tile URL and either pick it server-side from a user preference or listen for the theme toggle and swap the tile layer. This is the most common "it looks broken" support ticket for map pickers in admin panels.
Livewire re-renders. If the map field sits inside a section that is re-rendered by a sibling field's live() call, Leaflet can be destroyed and re-created, losing the user's pin. Keep the map field's section out of the reactive dependency chain, or add ->live(false) to its neighbors. If you must re-render, persist the pin in state and let the component re-hydrate from state — which it does by default — rather than holding the pin only in JS.
Multiple maps and repeaters. It works in a Repeater, but each row spawns a Leaflet instance. Ten rows is fine; a hundred will jank the browser. For large lists, switch to a "click to open a modal picker" pattern instead of inline maps per row.
Geocoding, if present, is rate-limited. If the version you install includes address search, it almost certainly calls Nominatim. That endpoint requires a valid User-Agent, allows roughly one request per second, and will ban you for per-keystroke queries. Debounce at 500ms+, cache results, and don't ship it to a public-facing form without your own geocoder in front of it.
Precision. float casts lose nothing meaningful, but if you round on save (round($lat, 6)) you're at roughly 10cm — plenty. Rounding to 3 decimals (~110m) will visibly misplace pins on a zoomed-in map, which looks like a package bug and isn't.
Extending it. The package has a small surface, which is a feature: subclass LeafletMapPicker, override getViewData() or the view, and add Leaflet plugins (marker clustering, Geoman drawing tools, a radius overlay) without waiting on upstream. Given 40 stars and a single maintainer, assume you will need to fork a Blade file at some point — structure your app so that's one file, not scattered overrides.
Version constraints before you commit. Confirm the installed tag's composer.json supports your Filament major (v3 vs v4 differ in field API details). Pin the package version in composer.json and re-run the asset build on upgrade; for a package this small, an unreviewed composer update is how you get a map that renders but silently stops dehydrating.
Debugging checklist, in order. (1) Browser console for L is not defined → assets not published. (2) Tiles visible but no pin → state not hydrating, dd() the field state. (3) Pin saves but reads back null → missing $casts. (4) Map fine on full page, broken in modal → container size/invalidateSize. (5) Works locally, not in prod → CSP or asset build step.
How can I help you explore Laravel packages today?