humaidem/filament-map-picker
Filament Map Picker provides an interactive map form field for Laravel Filament apps. Users can search, drop a pin, and save latitude/longitude coordinates, making location selection simple in resources and forms.
Install
composer require humaidem/filament-map-picker
Optionally publish the config to set global defaults (tile URL, zoom, marker/draggable/clickable behavior):
php artisan vendor:publish --tag="filament-map-picker-config"
The field ships a Blade view plus Leaflet assets and is auto-discovered by Filament v3 — no plugin registration in your PanelProvider is normally required. If the map renders as an unstyled grey box, that's the first sign the Leaflet CSS/JS didn't load; re-check asset publishing and clear php artisan filament:cache-components.
Storage
The field's state is a PHP array shaped like ['lat' => -6.2, 'lng' => 106.816666]. The simplest schema is a single JSON column:
$table->json('location')->nullable();
…and a cast on the model:
protected $casts = ['location' => 'array'];
First use case
Drop it into an existing Resource form and store a point on a record:
use Humaidem\FilamentMapPicker\Fields\MapPicker;
MapPicker::make('location')
->label('Location')
->defaultLocation([-6.2, 106.816666]) // [lat, lng]
->showMarker()
->draggable()
->extraAttributes(['style' => 'min-height: 400px']);
Before wiring it up, the first thing to inspect is the actual state shape your version produces: add ->afterStateHydrated(fn ($component, $state) => logger($state)) and load an edit page. Older/newer tag combinations have differed on whether state arrives as an array or a "lat,lng" string, and everything downstream depends on knowing which one you have.
Two-column mapping (lat/lng in separate columns)
If your schema has latitude / longitude, adapt at the boundaries rather than changing the field:
MapPicker::make('location')
->afterStateHydrated(function (MapPicker $component, $record) {
if ($record?->latitude && $record?->longitude) {
$component->state([
'lat' => $record->latitude,
'lng' => $record->longitude,
]);
}
})
->dehydrated(false) // handled manually below
…then in the page/resource:
protected function mutateFormDataBeforeSave(array $data): array
{
$data['latitude'] = $data['location']['lat'] ?? null;
$data['longitude'] = $data['location']['lng'] ?? null;
unset($data['location']);
return $data;
}
Reverse geocoding on pick
The package has no geocoder, so hook Livewire state updates and call your provider (Nominatim, Google Geocoding, Mapbox) yourself:
MapPicker::make('location')
->live()
->afterStateUpdated(function ($state, Set $set) {
if (! is_array($state)) return;
$response = Http::get('https://nominatim.openstreetmap.org/reverse', [
'lat' => $state['lat'], 'lon' => $state['lng'], 'format' => 'json',
])->json();
$set('address', $response['display_name'] ?? null);
});
Debounce the calls (->debounce(800)) and cache results — Nominatim's usage policy is strict.
Table and infolist display
The stored array isn't human-readable, so format the column yourself and link out:
Tables\Columns\TextColumn::make('location')
->formatStateUsing(fn ($state) => $state
? "{$state['lat']}, {$state['lng']}"
: '—')
->url(fn ($record) => $record->location
? "https://www.openstreetmap.org/?mlat={$record->location['lat']}&mlon={$record->location['lng']}#map=18"
: null)
->openUrlInNewTab();
Standardize across panels
Because the field has many per-instance options (zoom, maxZoom, minZoom, tilesUrl, showMarker, draggable, clickable, showMyLocationButton), wrap it in your own subclass so app-wide conventions live in one place:
// app/Filament/Fields/LocationPicker.php
class LocationPicker extends MapPicker
{
protected function setUp(): void
{
parent::setUp();
$this->showMarker()->draggable()->zoom(16)
->extraAttributes(['style' => 'min-height: 380px; border-radius: .5rem']);
}
}
Repeater / Tab / Wizard contexts
Maps inside hidden containers initialize at zero height. Keep the map in the initially-visible tab, or trigger a resize from Alpine on tab change; wire:ignore on the wrapper prevents Livewire from tearing down the Leaflet instance during unrelated re-renders.
defaultLocation() and state have different shapes. defaultLocation([lat, lng]) is a numeric pair; the state is an associative array. Mixing them up silently places the marker at null, null.tiles_url in the published config for MapTiler, Mapbox, Carto, or your own tile server before launch.cheesegrits/filament-google-maps, dotswan/filament-map-picker).extraAttributes(['class' => '...']).showMyLocationButton() requires HTTPS. Browser geolocation fails silently on http:// local hosts; test behind valet share/ngrok or a proper cert.->required() on the field still passes if the array contains ['lat' => null, 'lng' => null]. Add ->rules(['array:lat,lng']) plus a custom rule that both values are numeric and within bounds.composer.json, test carefully through Filament minor upgrades, and treat Filament v4 as unverified until you've checked the field yourself. For a core feature, a small fork (or a thin wrapper around Leaflet in your own Blade view via getView()) is a reasonable hedge.Leaflet is not defined in console, (3) container min-height set, (4) state shape logged via afterStateHydrated, (5) tile URL reachable (CSP img-src/connect-src headers in production).getView() if you need extra UI (search box, "clear location", coordinate readout).How can I help you explore Laravel packages today?