traineratwot/filament-openstreetmap
Add a free OpenStreetMap field to Filament forms with Leaflet-powered maps. Pick points visually and save as WKT or arrays, with easy Composer install and publishable assets.
Install and publish assets — both steps. The map renders nothing without the vendored Leaflet bundle in public/:
composer require traineratwot/filament-openstreetmap
php artisan vendor:publish --tag=filament-openstreetmap-assets
# copies Leaflet JS/CSS to public/vendor/filament-openstreetmap/leaflet/
v2.1.0 shipped a release note consisting solely of "RUN this" for that publish command — treat it as mandatory on every upgrade, not just first install.
Where to read first, in this order:
src/Forms/Components/MapInput.php — the field and every fluent option (saveFormat(), markers(), markersOnly(), zoom()).src/Data/Point.php — the value object. This is the part you'll use most outside the form.src/Enums/PointFormat.php — the 11 storage/output formats; pick your storage format here before touching a migration.src/Casts/PointCast.php — how the DB column round-trips into Point.First use case — a coordinate picker on a Venue/Store model. Migration with a plain string column, cast it, drop the field in the resource:
// migration
$table->string('point')->nullable();
// model
protected function casts(): array {
return ['point' => \Traineratwot\FilamentOpenStreetMap\Casts\PointCast::class];
}
// resource form
MapInput::make('point')
->columnSpanFull()
->saveFormat(PointFormat::WKT)
->required();
Keep the column nullable() and store the format next to the cast, not scattered through the codebase — you will change your mind about WKT vs. lat/lng once, and it should be a one-line change.
Sanity check before building on it: load the form, click the map, save, and read the raw DB value plus $model->point after refresh. Confirm the stored string and the hydrated Point agree. If the map is blank, it's the asset publish 95% of the time; check the browser console for a 404 on /vendor/filament-openstreetmap/leaflet/leaflet.js.
Pick the storage format by what you'll do next, not by what looks nice in the DB.
| You plan to… | Store as | Column |
|---|---|---|
Add PostGIS / matanayadev/laravel-eloquent-spatial later |
PointFormat::WKT or GEOJSON |
string (or geometry) |
| Just read lat/lng in PHP/Blade | PointFormat::LAT_LNG or ARRAY |
string / json |
| Hand a link to a human | URL_GOOGLE / URL_OSM |
string |
Store a canonical format and derive the display format. Do not store Google Maps URLs as your source of truth — they're an output.
Use Point everywhere outside the form. It's the real API of this package:
use Traineratwot\FilamentOpenStreetMap\Data\Point;
use Traineratwot\FilamentOpenStreetMap\Enums\PointFormat;
$p = new Point(55.7558, 37.6173);
$p->format(PointFormat::WKT); // POINT(37.6173 55.7558)
$p->format(PointFormat::URL_OSM); // link for a table action
$p = Point::fromValue($request->input('point')); // any of the 11 formats, auto-detected
This is how you get coordinates in from an API/import (CSV of DMS strings, GeoJSON payloads) and out to a <a href> in a Filament TextColumn/TextEntry. Seeder and importer work becomes a Point::fromValue() map, no Leaflet involved.
Table/infolist display. Don't render the raw WKT string to operators:
TextColumn::make('point')
->formatStateUsing(fn ($state) => $state?->format(PointFormat::LAT_LNG))
->url(fn ($state) => $state?->format(PointFormat::URL_OSM), shouldOpenInNewTab: true),
Markers for guided selection. Turn a free-form picker into a constrained one when the domain is a fixed list (branch offices, warehouses, service depots):
MapInput::make('point')
->markers(fn () => auth()->user()->locations->map(fn ($l) => [
'title' => $l->name,
'point' => [$l->lat, $l->lng], // [lat, lng] — see gotchas
'color' => $l->is_active ? '#22c55e' : '#ef4444',
])->toArray())
->markersOnly(fn () => ! auth()->user()->can('place-anywhere'))
Closures are supported for both markers() and markersOnly(), so per-record and per-role restrictions are cheap. markersOnly() also switches the search box into a marker filter rather than a geocoder query — a nice free upgrade to UX on constrained fields.
Geocoding (v2.2.0+) is built into the field: a Nominatim-backed search bar with 400 ms debounce, dark-mode styling, and en/ru translations. There's no API key and no config to write. Use it as the "type an address" escape hatch on top of click-to-place; you don't need to build a separate address field.
Distance/nearest queries without spatial extensions:
$origin = new Point($lat, $lng);
$branches->sortBy(fn ($b) => $origin->distanceTo($b->point))->first();
Fine for tens or hundreds of rows. Push to a spatial column + index when it's thousands.
Localization: publish the translation files and add your locale alongside en/ru; the key is filament-openstreetmap::input.placeholder.
The lat/lng vs. lng/lat trap is real and unguarded. Point holds (latitude, longitude) internally, but WKT and GeoJSON are (longitude, latitude). Point::fromValue() auto-detects from string shape, which means "55.7558,37.6173" will happily parse — but whether your stored string is lat-first or lng-first depends entirely on the PointFormat you chose. Add a validation rule or a model accessor assertion (e.g. reject |lat| > 90 in the first slot) and write one round-trip test per format you actually use. This is the single most likely source of silently wrong pins.
$wire.entangle() returns a Livewire Proxy, not a string. If you customize resources/views/forms/components/map.blade.php, remember the proxy is {initialValue: '...', _x_interceptor: true}. The package unwraps val.initialValue in _parseCoords() and binds x-model="stateString" (a plain JS string) rather than x-model="state". Repeating that mistake gives you [object Object] in the display input and a frozen map.
wire:ignore means the map doesn't re-render on Livewire updates. Marker arrays passed as closures are evaluated at render; a reactiveTo() or live()-driven change won't move markers in place. Either force a re-mount (->key(), or toggle a wrapper component) or drive updates with your own JS on the Leaflet instance. Don't assume reactivity works because the field extends the Filament base class.
Asset publishing is a deploy step, not a one-off. public/vendor/ is normally gitignored, so your pipeline needs php artisan vendor:publish --tag=filament-openstreetmap-assets --force (or a copy step) on every release. Forgetting it after an upgrade produces a blank map in production and a working one locally — a classic. Better: vendor the package source into your repo and pin Leaflet yourself, since you also inherit Leaflet security patches.
v2.0.0 was a full rewrite and is explicitly not compatible with v1. Any blog post or Stack Overflow answer using v1 namespaces or the old cast is wrong. Also note the README's cast snippet references PointCast::class without an import — the real FQCN is Traineratwot\FilamentOpenStreetMap\Casts\PointCast. Aliasing the cast to a short name in your model is worth it for readability.
Nominatim has a usage policy. The search bar calls it from the browser: 1 request/second max, and it expects an identifying User-Agent/Referer. The 400 ms debounce is not a rate limiter. For a busy back office, either accept it (single operator, fine) or point the search at your own geocoder or a server-side proxy. Same story for tiles — OSM's tile servers are donation-funded; at any real volume use your own tile provider. Check whether the current version exposes a tile URL option; if not, that's the first thing to patch in your fork.
Debugging checklist, in order: (1) does public/vendor/filament-openstreetmap/leaflet/leaflet.js exist? (2) browser console — Leaflet 404 or a JS error before Alpine init? (3) is the DB value non-null and does $model->point return a Point? (4) is your saveFormat the one you think it is? (5) does the value fromValue() round-trip in php artisan tinker?
Treat this as a fork, not a dependency. 28 stars, zero dependents, no PHPUnit/PHPStan in the package root, testing is a manual laravel-test/ app, single maintainer, en/ru only. Vendor the source or fork it, wrap MapInput in your own App\Forms\Components\LocationPicker so the package name appears in exactly one file, and write your own feature test that saves a point through Filament and asserts the stored string. That wrapper is also your escape hatch if the package stalls — swapping the internals later becomes a one-file change.
How can I help you explore Laravel packages today?