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 Pinpoint Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. 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.
  2. Fahiem\FilamentPinpoint\Pinpoint and PinpointEntry — read the public method signatures; the README table is more complete than the prose examples.
  3. The 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.


Implementation Patterns

Pattern 1: The "location block" partial

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.

Pattern 2: Provider strategy — global default, per-field exception

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.

Pattern 3: Radius as a delivery/service area

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.

Pattern 4: Repeater — never manually prefix paths

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.

Pattern 5: Read-only as a policy surface

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.

Pattern 6: Infolists for multi-site views

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

Integration tips

  • Wrap it. Put a thin App\Filament\Fields\LocationPicker that extends Pinpoint and calls the standard chain. When you swap packages in 18 months, one file changes.
  • Policy-aware forms. Gate the whole location block with ->visible(fn ($record) => $record?->has_coordinates ?? true) on edit forms for records imported without coordinates.
  • Seed data. Write a factory helper that pairs lat/lng with a matching address string — otherwise every test record shows a marker in the ocean at 0,0.

Gotchas and Tips

Provider & API key pitfalls

  • The default provider is Google, not Leaflet. If you install, see a grey box, and have no key, that's not a bug. Set 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.
  • The key ships to the browser. Restrict it by HTTP referrer in Google Cloud Console. A key that works server-side in your local .env will be scraped within days if unrestricted.
  • Nominatim is rate-limited to ~1 request/second and requires a descriptive 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.

Defaults that will bite you

  • The default map centre is Indonesia (-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.
  • Dark tiles are opt-in. Leaflet dark mode falls back to the light tile URL unless 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.

The location field's own state

Pinpoint::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".

Autofill targets

  • Targets must be real form components in the same schema. ->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.
  • Autofill writes into form state; if the target is ->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.

Repeater & multi-instance quirks

  • Each repeater item gets its own map instance. Ten items on one page is ten map initialisations, ten tile sets, and on Google, ten billable loads. Cap repeater ->maxItems() on location-heavy forms.
  • Adding/removing repeater items re-renders maps; keep ->height() modest (300) for repeater contexts and leave 400–500 for full-width single fields.

Debugging checklist

When the map renders but nothing saves:

  1. Confirm lat/lng columns exist and are nullable-or-defaulted.
  2. Check ->latField() / ->lngField() names match the column names exactly.
  3. Dump $livewire->data after a marker drag — if lat/lng are populated there, the problem is dehydration, not the map.
  4. Open the browser console: Google failures surface as 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.

Maturity risk

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:

  • Pin the version in composer.json ("fahiem/filament-pinpoint": "^1.1") and review the changelog on bump. UI components like this regress on minor releases.
  • Isolate it behind your own wrapper class and your own column conventions (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.

Extension points

  • vendor:publish --tag="filament-pinpoint-config" for config.
  • Check 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 override per field (->provider('leaflet')) is the cheapest way to A/B Google vs OSM on one form without touching .env.
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.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package