fahiem/filament-pinpoint
A Filament 4 & 5 location picker field with Google Maps or Leaflet.js (OpenStreetMap) support, offering search, click-to-set and draggable markers, radius display, reverse geocoding, dark mode, and translations.
Install and pick a provider before writing any field code. The provider choice is the single biggest architectural decision here, because it determines your billing, API-key management, and search quality.
composer require fahiem/filament-pinpoint
php artisan vendor:publish --tag="filament-pinpoint-config"
# .env — pick one
PINPOINT_PROVIDER=leaflet # free, no key, Nominatim search
# PINPOINT_PROVIDER=google # default; needs a key
GOOGLE_MAPS_API_KEY=...
GOOGLE_MAPS_DEFAULT_LAT=-6.200000
GOOGLE_MAPS_DEFAULT_LNG=106.816666
GOOGLE_MAPS_DEFAULT_ZOOM=15
Where to look first, in this order:
config/filament-pinpoint.php after publishing — this is the real source of truth for provider defaults, tile URLs, and zoom. The README's method table only covers per-field overrides.Fahiem\FilamentPinpoint\Pinpoint and PinpointEntry — read the public method signatures; the README table is more complete than the prose examples.resources/ directory of the package — the JS is where the reverse-geocoding behaviour actually lives, and you will need it when autofill doesn't hit the field you expected.First use case — the two-column lat/lng pair. Pinpoint does not own your coordinates; it writes into sibling form fields. So the minimum viable wiring is always three components:
use Fahiem\FilamentPinpoint\Pinpoint;
use Filament\Forms\Components\TextInput;
Pinpoint::make('location')
->label('Location')
->latField('lat')
->lngField('lng'),
TextInput::make('lat')->numeric()->step(0.0000001),
TextInput::make('lng')->numeric()->step(0.0000001),
Get this working on one resource before you touch address autofill, radius, or repeaters. Every one of those is additive; the coordinate pair is the contract.
Schema prerequisite: lat and lng need real database columns — use decimal(10,7) (or 11,8 for lat). float/double will drift and produce markers that shift a few metres between saves. Make lng nullable if you allow "location not set" records.
Every resource with an address needs the same eight to ten fields. Extract it once rather than copy-pasting the field list:
// app/Filament/Support/Concerns/HasLocationFields.php
public static function locationSchema(): array
{
return [
Pinpoint::make('location')
->latField('lat')
->lngField('lng')
->addressField('address')
->cityField('city')
->provinceField('province')
->postalCodeField('postal_code')
->countryField('country')
->height(360)
->columnSpanFull(),
Forms\Components\Hidden::make('lat'),
Forms\Components\Hidden::make('lng'),
TextInput::make('address')->columnSpanFull(),
TextInput::make('city'),
TextInput::make('province'),
TextInput::make('postal_code'),
TextInput::make('country'),
];
}
Note the deliberate choice to make the autofilled address fields editable, not readOnly(). The README's examples mark them read-only; in practice Nominatim and even Google return the wrong locality often enough (Indonesian kelurahan vs kecamatan especially) that locking the field creates support tickets. Let users correct the text; the coordinates are what matter.
Don't hardcode ->provider() on every field. Set PINPOINT_PROVIDER per environment and only override where a specific form needs richer search:
// Global default = leaflet for cost. Upgrade only the high-value form.
Pinpoint::make('location')
->provider('google') // sales territory planning needs Places quality
->latField('lat')->lngField('lng'),
This gives you a clean downgrade path: if the Google bill spikes, drop the ->provider('google') calls and the whole app falls back to OSM with zero schema changes.
The radius circle is only useful if the value is queryable. Treat radius as a first-class business column, not a map decoration:
Pinpoint::make('location')
->latField('lat')
->lngField('lng')
->radiusField('radius')
->defaultRadius(500), // metres
// Later: does this order fall inside the branch service area?
$inRange = $branch->radius >= $this->haversineMetres(
$branch->lat, $branch->lng, $order->lat, $order->lng
);
Store it as unsignedInteger. The circle updates and persists in real time as the handle is dragged, so it needs a column before the form is ever opened.
Repeater::make('branches')
->schema([
TextInput::make('branch_name')->required(),
Pinpoint::make('location')
->latField('latitude')
->lngField('longitude')
->addressField('address')
->height(300),
TextInput::make('latitude')->readOnly(),
TextInput::make('longitude')->readOnly(),
TextInput::make('address')->columnSpanFull()->readOnly(),
])
->columns(2)
->columnSpanFull(),
The package resolves data.branches.0.latitude itself. The classic mistake is writing ->latField('branches.latitude') to "help" — that breaks silently, with the map working but nothing saving.
readOnlyOn('view') is the pattern to reach for on resources with a separate view page. Reserve ->readOnly(fn () => ...) for permission-driven cases:
// View page + users who can't edit, in one call
Pinpoint::make('location')
->readOnlyOn(['view'])
->readOnly(fn () => ! auth()->user()->can('edit-location'))
->latField('lat')->lngField('lng'),
Read-only keeps the map pannable and zoomable — it's a genuine "look at this" mode, not a grey box. Use it for audit screens and for archived records where coordinates must not drift.
PinpointEntry with pins() is the one place this package does something a plain text column can't. Build the pins from a relationship in a closure rather than hardcoding:
PinpointEntry::make('branches')
->pins(fn ($record) => $record->branches->map(fn ($b) => [
'lat' => (float) $b->lat,
'lng' => (float) $b->lng,
'label' => $b->name,
'color' => $b->is_primary ? 'red' : 'blue',
'info' => "<strong>{$b->name}</strong><br>{$b->address}",
])->all())
->fitBounds()
->height(500)
->columnSpanFull(),
The info key accepts raw HTML — escape any user-supplied string yourself. Cast to float explicitly; string coordinates from a decimal column are the most common cause of "map renders, no pins".
App\Filament\Fields\LocationPicker that extends Pinpoint and calls the standard chain. When you swap packages in 18 months, one file changes.->visible(fn ($record) => $record?->has_coordinates ?? true) on edit forms for records imported without coordinates.lat/lng with a matching address string — otherwise every test record shows a marker in the ocean at 0,0.PINPOINT_PROVIDER=leaflet or add the key — and remember the key needs three APIs enabled (Maps JavaScript, Places, Geocoding). Enabling only the first gives you a working map with dead search and no autofill, which is a confusing partial failure..env will be scraped within days if unrestricted.User-Agent. Dragging a marker across a city fires a reverse-geocode per drag-end. On any form with moderate traffic you will get 429s — either self-host Nominatim, point NOMINATIM_URL at a proxy, or disable autofill on high-traffic forms.-0.5050, 117.1500) with zoom 13. If your app is not Indonesian, every new record opens looking at the Makassar Strait. Set GOOGLE_MAPS_DEFAULT_LAT/LNG or ->defaultLocation() per field on day one — it's the first thing reviewers notice.height() is an integer in pixels. Passing 400 fine, "400px" won't be.LEAFLET_TILE_URL_DARK is set. The map container goes dark but the tiles don't — it looks broken rather than unstyled. Set it to a CartoDB dark URL.location field's own statePinpoint::make('location') needs a form-state path to exist, but you're storing lat/lng/address elsewhere. If you have no location column, either add a nullable one, or dehydrate it away:
Pinpoint::make('location')->dehydrated(false)->latField('lat')->lngField('lng'),
Symptom of getting this wrong: a SQLSTATE: column not found: location on save, or a validation error on a field the user never saw. Check this first when "it works in the form but won't save".
->addressField('address') with no TextInput::make('address') produces a silent no-op — the map reads the geocode, finds nowhere to put it, and moves on. No console error.->disabled() rather than ->readOnly(), Filament excludes it from dehydration and the value is discarded on save. Use readOnly().shortAddressField() deliberately strips province/city/district/village/postal-code. It's a derived display string — don't repurpose it as your canonical address.->maxItems() on location-heavy forms.->height() modest (300) for repeater contexts and leave 400–500 for full-width single fields.When the map renders but nothing saves:
lat/lng columns exist and are nullable-or-defaulted.->latField() / ->lngField() names match the column names exactly.$livewire->data after a marker drag — if lat/lng are populated there, the problem is dehydration, not the map.RefererNotAllowedMapError / ApiNotActivatedMapError; Leaflet failures are usually CORS on the tile URL.When the marker renders but the address fields stay empty: it's almost always a missing target component, a rate-limited Nominatim, or (Google) an unenabled Geocoding API. Check Network tab for the geocode request before touching PHP.
This is a 19-star package with zero dependents and a fast release cadence (v1.1.x features — radius, multi-pin, read-only — landed in recent patch versions). Treat that as a two-part instruction:
composer.json ("fahiem/filament-pinpoint": "^1.1") and review the changelog on bump. UI components like this regress on minor releases.lat/lng, not the package's location naming). If you ever need to swap to a hand-rolled Leaflet field, the migration should touch one file plus the config.vendor:publish --tag="filament-pinpoint-config" for config.php artisan vendor:publish for a views/assets tag before you start overriding anything — the package is Blade + Alpine based, so a single tile-URL or button-visibility tweak is usually a view publish away.->provider('leaflet')) is the cheapest way to A/B Google vs OSM on one form without touching .env.How can I help you explore Laravel packages today?