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

Filament Map Picker Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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 field
  • Dotswan\MapPicker\Infolists\MapEntry — the read-only display

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

Implementation Patterns

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:

  1. latitude / longitude as real decimal(10,7) columns, cast in the model.
  2. location as a non-fillable, non-persisted form field.
  3. afterStateUpdated to split the array into the real columns.
  4. 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.

Gotchas and Tips

  • Array state breaks scalar validation. ->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.
  • The default 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.
  • The field can collapse to a thin strip. Leaflet needs an explicit height; use ->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.
  • GeoMan's GeoJSON gets HTML-sanitized somewhere in the round trip. The README's own hydration example does 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.
  • Don't feed 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.
  • Zero dependents is a real signal. With 136 stars and no downstream packages, you are the test suite. Pin the version, screenshot-test the field in your own CI if it's business-critical, and read the CHANGELOG before minor upgrades.
  • Debugging workflow: coordinates look wrong → log $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.

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.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log