dotswan/filament-map-picker
A Filament v3 custom field for picking locations on an OpenStreetMap map and retrieving coordinates in real time. Includes GeoMan integration for drawing, editing, and manipulating markers, polygons, polylines, and circles.
Install and pin to your Filament major. The package is a single Composer install, but version selection matters more than usual here because the README explicitly splits guidance by Filament version:
composer require dotswan/filament-map-picker
# Filament v3 explicitly
composer require dotswan/filament-map-picker:"^1.8"
Check the releases page before you start a project on it — the field is maintained by a single vendor, so treat the version constraint as part of your lockfile discipline rather than something you float.
Where to look first. Skip the screenshots on the repo README and go straight to two things: the options table (it is the real API surface) and the two components you will actually instantiate:
Dotswan\MapPicker\Fields\Map — the form fieldDotswan\MapPicker\Infolists\MapEntry — the read-only displayEverything else is a fluent setter on those two classes. If a method isn't in the table, it doesn't exist.
First use case — a place picker on a resource. The canonical job for this package is "user clicks a point on a map, you store coordinates." Add the columns first:
// migration
$table->decimal('latitude', 10, 7)->nullable();
$table->decimal('longitude', 10, 7)->nullable();
$table->json('geojson')->nullable(); // only if you use GeoMan
Then the field, wired to write back to flat columns:
use Dotswan\MapPicker\Fields\Map;
Map::make('location')
->label('Location')
->columnSpanFull()
->defaultLocation(latitude: 40.4168, longitude: -3.7038)
->draggable(true)
->showMarker(true)
->zoom(15)
->afterStateUpdated(function (Set $set, ?array $state): void {
$set('latitude', $state['lat'] ?? null);
$set('longitude', $state['lng'] ?? null);
})
->afterStateHydrated(function ($state, $record, Set $set): void {
$set('location', [
'lat' => $record?->latitude ?? 0,
'lng' => $record?->longitude ?? 0,
]);
});
That pair of hooks — hydrate from columns, flatten back into columns — is the entire integration. Once it saves correctly, everything else in the package is cosmetic or additive.
Treat location as a virtual field, not a persisted one. The field's state is an array (['lat' => ..., 'lng' => ..., 'geojson' => ...]), which is awkward to store in a relational column and awkward to validate. The clean pattern is:
latitude / longitude as real decimal(10,7) columns, cast in the model.location as a non-fillable, non-persisted form field.afterStateUpdated to split the array into the real columns.afterStateHydrated to reassemble it on edit.Guard the hydrate callback with $record?->latitude ?? 0 — on create the record is null and an unguarded $record->latitude crashes the page.
Programmatic map moves via Actions. Since the field is just Livewire state, you can drive it from elsewhere on the form. The package listens for a refreshMap event; you must dispatch it or the marker won't visually move even though the state changed:
Actions::make([
Action::make('setDefaultLocation')
->icon('heroicon-m-map-pin')
->action(function (Set $set, $livewire): void {
$set('location', ['lat' => 52.3551, 'lng' => 4.8834]);
$set('latitude', 52.3551);
$set('longitude', 4.8834);
$livewire->dispatch('refreshMap');
}),
]),
This is how you build things like "use my address" buttons or "reset to HQ" — set both the array and the flat fields, then dispatch.
Use MapEntry for read-only views. Don't render the form field in an infolist or view page. MapEntry accepts the same fluent config but takes closures for the coordinates, which is what makes it usable per-record:
MapEntry::make('location')
->defaultLocation(
latitude: fn ($record) => $record?->latitude ?? 0,
longitude: fn ($record) => $record?->longitude ?? 0
)
->draggable(false)
->showMarker(true)
->extraStyles(['min-height: 40vh', 'border-radius: 8px']);
Note draggable(false) — a view page shouldn't let people pan a map and think they changed something.
GeoMan only when you need geometry. ->geoMan(true) turns on the full drawing toolbar with most tools enabled by default (markers, polygons, circles, cut/rotate/edit). For a simple pinpoint picker that's noise. Turn it on surgically:
->geoMan(true)
->geoManEditable(true)
->geoManPosition('topleft')
->drawMarker(true)
->drawPolygon(true)
->drawPolyline(false)
->drawCircle(false)
->drawRectangle(false)
->drawText(false)
->rotateMode(false)
->cutPolygon(false)
->deleteLayer(true)
The GeoJSON output lands in $state['geojson'] — json_encode it into a json column and cast it on the model. Because GeoJSON can be arbitrarily large, don't put it in the same column as anything you intend to index or LIKE against.
rangeSelectField for radius/coverage UIs. If you're modelling "service radius" or "delivery zone," point the map at an existing numeric field measured in meters and the circle redraws as that field changes:
Select::make('membership_distance')->options(MembershipDistance::class),
Map::make('location')
->rangeSelectField('membership_distance')
->setFilledColor('#cad9ec'),
This is the cheapest way to make a distance field tangible without writing any JS.
Boundaries for city/region-scoped apps. ->boundaries(true, $south, $west, $north, $east) clamps the viewport. Pair it with minZoom() or users can zoom out and see the box bouncing on them. The setBoundsToBritishIsles() convenience is a decent template for your own helper.
Live location for field-agent forms. ->liveLocation(true, true, 10000) pushes the browser's position to the server on an interval. Use it when the form is used in the field; avoid it on desktop admin forms where it just generates polling traffic.
->required() on the map field validates the array, not the coordinates. If you need "user must pick a point," validate latitude/longitude with ->required() on the flat fields, and/or a ->rule() closure on the map that rejects 0,0.defaultLocation(0, 0) is the null island. Unset coordinates render a marker in the Atlantic. Either use ->showMarker(false) when there's no location, or explicitly hydrate and gate the marker on $record?->latitude.refreshMap is mandatory after external $set. State changes alone don't re-render the Leaflet instance. Every programmatic update needs the $livewire->dispatch('refreshMap') call.tilesUrl is http://. On an HTTPS site that's mixed content and tiles silently fail. Always pass an explicit HTTPS tile URL:
->tilesUrl('https://tile.openstreetmap.de/{z}/{x}/{y}.png')
Also mind OSM's tile usage policy — for anything beyond light internal use, point at your own tiles or another provider.->extraStyles(['min-height: 40vh']) (or similar) if the map looks clipped inside a Fieldset.afterStateHydrated runs before $record exists on create. Guard with null-safe operators. Also remember it runs on every hydration, including after actions — don't put side effects like DB writes in there.json_decode(strip_tags($record->description)). Store GeoJSON in a dedicated json column and decode it directly rather than round-tripping through an HTML field; if you're stuck on an existing HTML column, strip_tags is the workaround.markerHtml() user data. It's injected into the DOM as-is. If it comes from a record, it's an XSS vector — sanitize or hardcode it.$state in afterStateUpdated first (is it lat/lng or the flat fields that are stale?); map doesn't move → check the refreshMap dispatch; map is blank → check the tile URL scheme and the container height; polygon data missing → check whether you json_encoded before saving.Extension point worth knowing: everything is fluent public setters on the Map class, so subclassing Dotswan\MapPicker\Fields\Map and wrapping common config in something like ->asPlacePicker() is a low-risk way to standardize across resources without forking the package.
How can I help you explore Laravel packages today?