agelgil/filament-maps
Filament Maps adds interactive map fields to Filament forms, letting admins pick and save coordinates directly in your panel. Easy to install and configure, it brings location selection into your existing forms without extra JavaScript work.
Install and register (5 minutes)
composer require agelgil/filament-maps
php artisan vendor:publish --tag="filament-maps-config"
php artisan filament:assets # publishes the field's JS/CSS into /public
Register the plugin on the panel(s) that need it:
// app/Providers/Filament/AdminPanelProvider.php
use Agelgil\FilamentMaps\FilamentMapsPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugin(FilamentMapsPlugin::make());
}
Set the defaults once, so you never pass a center per field:
// config/filament-maps.php
return [
'default_center' => ['lat' => 9.0300, 'lng' => 38.7400],
'default_zoom' => 13,
'height' => '400px',
];
Read these files first, in this order
config/filament-maps.php — the published config is the real spec: it names the underlying map provider, tile/API key handling, and the state shape the field emits. That state shape is the single thing everything else in your app depends on.src/Forms/Components/*.php — the field class's setUp() shows the default height, defaultCenter, defaultZoom, and how state is serialized (array ['lat' =>, 'lng' =>] vs. a "lat,lng" string).First use case: pin a location on a Store record
$table->decimal('latitude', 10, 7)->nullable();
$table->decimal('longitude', 10, 7)->nullable();
use Agelgil\FilamentMaps\Forms\Components\MapPicker;
MapPicker::make('location')
->label('Pin location')
->columnSpanFull()
->required(),
The field is a picker, not a model binding: it edits one composite value, your schema stores two columns. Bridging that is your first real task — see the next section.
Verify before writing code: the package's public class name (
MapPickervs.MapFieldvs.Map) and its plugin class are inferred from the description, not from published docs.grep -r "class .* extends Field" src/will settle it in ten seconds. Same for the state shape.
Keep the split in one place per resource by overriding the page hooks rather than sprinkling dehydrateStateUsing across fields:
// app/Filament/Resources/StoreResource/Pages/CreateStore.php
protected function mutateFormDataBeforeCreate(array $data): array
{
[$data['latitude'], $data['longitude']] = $this->split($data['location'] ?? null);
unset($data['location']);
return $data;
}
private function split(?array $point): array
{
return [
data_get($point, 'lat'),
data_get($point, 'lng'),
];
}
On the Edit page you need the reverse — hydrate the composite state from the two columns:
MapPicker::make('location')
->dehydrated(false) // page hooks own persistence
->formatStateUsing(fn (?Store $record) => $record?->latitude
? ['lat' => (float) $record->latitude, 'lng' => (float) $record->longitude]
: null),
This split is worth putting in a HasMapCoordinates trait (coordinates() / setCoordinates() accessors) because you will do it on every geolocated resource, and it's also the seam that lets you swap this package later without touching resources.
The package places points; it does not geocode. Wire it to a geocoder with a suffix action so the map stays the source of truth:
MapPicker::make('location')
->suffixAction(
Action::make('geocode')
->icon('heroicon-m-magnifying-glass')
->action(function (Get $get, Set $set) {
$point = app(Geocoder::class)->lookup($get('address'));
if ($point) {
$set('location', ['lat' => $point->lat, 'lng' => $point->lng]);
}
})
),
Run the geocode call server-side in the action, cache results by address string, and never call the provider from the field's JS — you'd leak the key and lose the ability to rate-limit.
Two distinct needs, and the field only covers the first:
radius_m integer.$results = Store::query()
->selectRaw(
'*, ST_Distance_Sphere(point(longitude, latitude), point(?, ?)) AS distance_m',
[$lng, $lat] // note: POINT() is lng, lat
)
->having('distance_m', '<', 5000)
->orderBy('distance_m')
->get();
->required() on the map is a UX claim you should validate, not assume — the field can dehydrate null or a half-filled array. Add an explicit rule so "one coordinate only" can never reach the database:
->rules([
fn (): Closure => function (string $attribute, $value, Closure $fail) {
$lat = data_get($value, 'lat');
$lng = data_get($value, 'lng');
if (($lat === null) !== ($lng === null)) {
$fail('Pick a point on the map.');
}
if ($lat !== null && ($lat < -90 || $lat > 90)) {
$fail('Latitude is out of range.');
}
if ($lng !== null && ($lng < -180 || $lng > 180)) {
$fail('Longitude is out of range.');
}
},
])
Because the map is JS-only, Livewire tests never execute it — you set the state directly and assert on the columns:
it('stores the pinned coordinates', function () {
livewire(CreateStore::class)
->fillForm([
'name' => 'Bole',
'location' => ['lat' => 8.9950, 'lng' => 38.7870],
])
->call('create')
->assertHasNoFormErrors();
$store = Store::first();
expect((float) $store->latitude)->toBe(8.9950)
->and((float) $store->longitude)->toBe(38.7870);
});
That single test is your regression net: it catches a state-shape change on upgrade, which is the most likely breakage.
Coordinate order will bite you once, in GeoJSON. The field is lat/lng; POINT() and GeoJSON are lng, lat. Write one toGeoJson() helper on your trait and never hand-roll the swap. Every "pin appears in the wrong ocean" bug is this.
Use decimal, not float. decimal(10,7) gives you three integer digits — enough for ±90 / ±180 at ~1 cm precision. FLOAT rounds on storage and makes equality assertions flaky. Remember ->cast('decimal:7') returns a string; (float) it before comparing.
Empty state is ambiguous. Depending on the field's dehydration, an untouched map can be null, ['lat' => null, 'lng' => null], or ''. Normalize in exactly one place (your split() helper) and let the validation rule above be the gate. Check this first when "I didn't touch the map and my coordinates got wiped" shows up in QA.
Map renders blank in four predictable situations:
filament:assets was never run, or public/ isn't writable in the deploy pipeline.Tabs/Wizard/Modal that starts hidden, or in a Repeater. Force a CSS height and call the map's invalidateSize() after show if the underlying library exposes it.config:cache was built before you changed default_center; re-run php artisan config:cache on deploy.Repeaters and repeatable map fields are the weak spot. Instantiating multiple map instances from one JS bundle is where JS-backed form fields usually break. Prototype it before committing to a "multiple locations per record" feature; if it fights you, fall back to a Repeater of read-only coordinate pairs with a single map for the primary point.
A 0-star, last-released-September-2024 package is a pin-and-isolate dependency, not a standard. Two concrete consequences:
"agelgil/filament-maps": "1.0.*") and commit the lockfile. With no issue tracker history to read, you cannot predict what a minor bump changes.CoordinatesPicker extends MapPicker and use that in resources. If you outgrow it or the field stops working on a future Filament version, you edit one file.Filament v4 is the real compatibility question. This package targets Filament v3 form schemas (schema() / ->form()), which v4 renamed and restructured. If you're starting a new panel today, confirm v4 support before designing around the field — otherwise plan to vendor and adapt the Blade view, which is small but non-zero work.
No spatial index, no scale. ST_Distance_Sphere over decimal columns is a full table scan. Fine for hundreds of stores; at tens of thousands, migrate to a POINT column with a SPATIAL INDEX (MySQL 8) or PostGIS — an additive migration, and your HasMapCoordinates trait is exactly where you absorb it.
Accessibility fallback. The field is click-and-drag only. If the coordinates are business-critical, expose two small numeric inputs (latitude/longitude) beside it, or a "paste coordinates" action — keyboard users and mobile field staff will need it, and it doubles as your debugging tool when the map won't load.
How can I help you explore Laravel packages today?