lara-zeus/filament-locationpickr-field
A Filament form field that lets users pick a location on an interactive map. Search for an address, drop a pin, and save latitude and longitude coordinates straight to your model—simple, lightweight map integration for Laravel admin panels.
Preconditions before composer require. This field is a thin adapter with two hard external dependencies: Filament 5 (v5.0.0's entire release note is "support filament 5" — treat it as a hard cutover, not dual support) and a billing-enabled Google Cloud project with Maps JavaScript API and Places API enabled. Read vendor/lara-zeus/filament-locationpickr-field/composer.json first; it is a faster and more honest source of PHP/Laravel/Filament floors than the README. If the panel is on Filament 4, stop here — adopting this becomes a framework migration.
composer require lara-zeus/filament-locationpickr-field:"^5.0"
Pin the constraint explicitly. With one star, zero dependents, and a single maintainer, ^5.0 floating into an untested patch is not a risk worth taking on a one-line changelog.
Provision the key before writing any PHP. Create a browser key restricted to HTTP referrers (your panel domains, including staging) and to Maps JavaScript API + Places API only. Put it in config/services.php and .env, not in a Blade template:
// config/services.php
'google_maps' => ['key' => env('GOOGLE_MAPS_BROWSER_KEY')],
Set quota caps and a budget alert in the same sitting. The org-side approval of that key is the real critical path; the coding is hours.
Publish and read the config. Publishing is where you discover the actual option names — do it before you write field code:
php artisan vendor:publish --tag="filament-locationpickr-field-config"
Then open the published config, the field class, and its Blade view. The package is small enough to read end-to-end in ten minutes, and doing so answers the questions the README won't: what column(s) it writes, whether it degrades without a key, and whether the map container is wire:ignore-wrapped.
Add the columns. Nullable decimals, seven places — roughly centimetre precision, enough for any door:
$table->decimal('latitude', 10, 7)->nullable();
$table->decimal('longitude', 10, 7)->nullable();
decimal(10,7) gives three integer digits and seven decimal places, which covers latitude ±90 and longitude ±180 in a single definition. Resist spatial columns for a picker; they buy nothing here and complicate every query and fixture.
First use case. One already-scoped screen — a venue, branch, or delivery-point resource — in a secondary panel. Register the field, open a real form, drag the marker, save, and confirm the row. That is the whole spike.
use LaraZeus\LocationPickr\LocationPickr; // verify exact FQCN against the installed version
LocationPickr::make('latitude')
->label('Location')
// confirm the real API: some versions expect a single field name that
// writes both coordinates, others expect a lat/lng column pair
;
Do not take the method names above on faith — the README at this adoption level is documentation of intent, not a contract. The field class is the source of truth.
Wrap the vendor field immediately. One internal class, twenty lines, and the swap-to-something-else cost drops from a survey to a diff:
namespace App\Filament\Fields;
use LaraZeus\LocationPickr\LocationPickr;
class MapPicker extends LocationPickr
{
// defaults, label, helper text, map options live here
}
Every resource references MapPicker, never the vendor namespace. When Google pricing, ToS, or the package's maintenance posture changes, you change one file.
Persist the pair plus its provenance. Coordinates alone are not recoverable — if the widget is replaced or a pin is obviously wrong, nobody can tell what it was meant to be. Store the raw provider context alongside:
$table->string('address_formatted')->nullable(); // display only, check ToS first
$table->decimal('latitude', 10, 7)->nullable();
$table->decimal('longitude', 10, 7)->nullable();
Read the field's save path before you decide what to persist beyond lat/lng. Google's Places caching rules restrict how long you may retain place IDs and some place data; if the field writes a place_id or formatted address into your model, that is a ToS question, not a storage convenience.
Cast to float on read, not string. Eloquent returns decimals as strings, which quietly breaks abs() comparisons, distance math, and JSON payloads:
protected function casts(): array
{
return [
'latitude' => 'float',
'longitude' => 'float',
];
}
If you need the string precision preserved for round-tripping, use 'decimal:7' instead and cast at the calculation boundary.
Validate the pair as a pair. The failure mode of a two-column location is half a location. A row with a latitude and no longitude is a bug that survives to production and renders a marker in the Gulf of Guinea:
->rules([
fn (Get $get) => $get('latitude') === null
? ['nullable']
: ['required', 'numeric', 'between:-90,90'],
])
Better: a Rule::requiredIf pair plus a model-level saving hook that nulls both if either is empty. Decide deliberately whether "no location" is null/null or 0/0 — it must be the former, because 0,0 is a real coordinate and will pollute every proximity query you ever write.
Keep a manual fallback visible. The field's behaviour on a missing key, exhausted quota, or blocked Maps JS is undocumented. Never let that be the only path to saving a record:
Grid::make(2)->schema([
MapPicker::make('latitude'),
TextInput::make('latitude_manual')->numeric()->hidden(fn () => $this->mapLoaded()),
])
Simplest robust variant: render the map field and two plain numeric inputs, and let ops paste coordinates when the map is blank. Degraded, not blocked.
Use defaults to fight re-panning. Set the map centre and zoom from config to your operating region once, in MapPicker, not per-field. A map that opens on the Atlantic every time is a small tax paid hundreds of times.
Display and navigate in tables. A TextColumn showing "51.5074, -0.1278" is enough, with a url() to https://maps.google.com/?q=lat,lng and openUrlInNewTab(). Do not embed a map in a table column; you will pay for a JS load per row.
Test the state, not the map. CI must not depend on Google. Assert form fill and persistence with Livewire test helpers — fillForm(['latitude' => 51.5, 'longitude' => -0.12]), assertFormSet, assertHasNoFormErrors, then assert the model columns. Browser-level smoke for the actual map is a nightly/manual job at best; a headless run against Google in CI is a quota leak and a flake source.
Do the geocoding server-side, in a job. If you need a normalised postal address, the field doesn't give you one. Dispatch a job after save that calls a geocoding service, writes the formatted address back, and caches the response — never call Places from a queued worker per keystroke, and never call it on every table render.
Secondary panel first, resource smoke test before and after. Plugin breakage in Filament is render-time, not boot-time. A test that boots the panel, hits the resource form route, and asserts 200 catches the lagging-plugin class of failure that php artisan about will not.
composer.lock revert documented. The usual failure is a Filament 5 app with one straggler plugin, and this is the straggler.RefererNotAllowedMapError means the referrer restriction doesn't cover that hostname (very often staging, or localhost with a port). A greyed map with a "development purposes only" watermark means billing isn't enabled on the project. Blank container with no console error usually means the assets didn't load or the container lost its Alpine init.localhost in a production key and makes cost attribution impossible.wire:ignore matters. Leaflet-family widgets break when Livewire re-renders their container. If the picker lives inside a repeater, a modal, or a form with ->live() fields, check the Blade view for wire:ignore and test the interaction order: open modal → set another field → touch the map. This is the single most likely bug you'll file.composer.lock revert, plus any republished config or views, plus a note that the database columns are plain decimals and survive the package's removal untouched. That last part is the payoff of refusing spatial types.How can I help you explore Laravel packages today?