These docs are a work in progress and may not be fully up to date. Some pages may contain internal notes for our team.
Skip to Content

Backend — autocomplete providers

Cross-references:

The autocomplete subsystem proxies address-search and place-detail requests from the widget to an upstream geocoding provider chosen per finder. All provider classes live under app/Services/Autocomplete/. The MappingController is the sole entry point for widget-originated requests.

Interface

All provider classes implement AutocompleteProviderInterface:

interface AutocompleteProviderInterface { public function search(string $input, ?float $lat, ?float $lng, ?string $sessionToken): array; public function placeDetails(string $placeId, ?string $sessionToken): ?array; }

app/Services/Autocomplete/AutocompleteProviderInterface.php:5-18

Sends the user’s typed input to the upstream provider and returns a normalized prediction array. Each element has the shape:

[ 'id' => string, // provider-specific place identifier 'description' => string, // full human-readable address 'main_text' => string, // city or primary label 'secondary_text' => string, // region / country suffix 'lat' => float|null, 'lng' => float|null, ]

Providers that include coordinates in their autocomplete response (Mapbox, HERE, Radar, Geoapify, DropaSearch) return non-null lat/lng. Providers that do not (Google) return null for both, which signals the widget to fire a placeDetails() call before placing the pin.

Optional parameters $lat/$lng bias results toward a geographic region. $sessionToken is forwarded to providers that support session-based billing (Google).

placeDetails()

Fetches full place data by the provider-specific id returned from search(). Returns the raw provider response array, or null when the provider already includes coordinates inline (all providers except Google). The widget treats a null result as a no-op and uses the lat/lng from the autocomplete response.

Factory

AutocompleteProviderFactory::make() is the single construction point for all provider instances:

class AutocompleteProviderFactory { public static function make(string $provider, ?string $apiKey): AutocompleteProviderInterface { return match ($provider) { 'google' => new GoogleProvider($apiKey ?? ''), 'radar' => new RadarProvider($apiKey ?? ''), 'mapbox' => new MapboxProvider($apiKey ?? ''), 'here' => new HereProvider($apiKey ?? ''), 'geoapify' => new GeoapifyProvider($apiKey ?? ''), default => new DropaSearchProvider(), }; } }

app/Services/Autocomplete/AutocompleteProviderFactory.php:6-18

The default arm handles both the 'dropaSearch' slug and any unrecognized slug by falling back to DropaSearchProvider, which requires no API key.

Provider classes

Six concrete classes are in production: five BYOK providers (GoogleProvider, MapboxProvider, HereProvider, RadarProvider, GeoapifyProvider) and one no-key fallback (DropaSearchProvider).

GoogleProvider

Slug: google
Upstream endpoints:

  • search()https://maps.googleapis.com/maps/api/place/autocomplete/json
  • placeDetails()https://maps.googleapis.com/maps/api/place/details/json

app/Services/Autocomplete/GoogleProvider.php:27-29, 55-68

placeDetails() requests fields=place_id,name,formatted_address,geometry,address_components. search() returns lat: null, lng: null for all predictions — the widget must fire placeDetails() for every selection.

Session token is forwarded as sessiontoken query param in both calls.

MapboxProvider

Slug: mapbox
Upstream endpoint:

  • search()https://api.mapbox.com/geocoding/v5/mapbox.places/{encoded_input}.json
  • placeDetails() → returns null (coordinates are included in search())

app/Services/Autocomplete/MapboxProvider.php:25-28

The input string is rawurlencode()d and interpolated into the path. Proximity bias is passed as proximity={lng},{lat} (Mapbox uses lng-first ordering). Types are restricted to place,address,region,postcode.

HereProvider

Slug: here
Upstream endpoint:

  • search()https://autocomplete.search.hereapi.com/v1/autocomplete
  • placeDetails() → returns null

app/Services/Autocomplete/HereProvider.php:24-27

Bias is passed as at={lat},{lng}. The HERE response includes position.lat/position.lng for most result types.

RadarProvider

Slug: radar
Upstream endpoint:

  • search()https://api.radar.io/v1/search/autocomplete
  • placeDetails() → returns null

app/Services/Autocomplete/RadarProvider.php:22-25

The API key is sent as a Bearer token header (withToken()), not as a query parameter.

GeoapifyProvider

Slug: geoapify
Upstream endpoint:

  • search()https://api.geoapify.com/v1/geocode/autocomplete
  • placeDetails() → returns null

app/Services/Autocomplete/GeoapifyProvider.php:25-28

Proximity bias is passed as bias=proximity:{lng},{lat} (Geoapify uses lng-first ordering). The response key for longitude is lon (not lng), normalized in the mapping.

DropaSearchProvider

Slug: dropaSearch (also the default match-arm in the factory)
Upstream endpoint:

  • search()https://nominatim.openstreetmap.org/search (OpenStreetMap Nominatim)
  • placeDetails() → returns null

app/Services/Autocomplete/DropaSearchProvider.php:26-30

This is the zero-config fallback — no customer API key is required or accepted. It uses the server-side user-agent Dropafinder/1.0 (cody@soivis.com) as required by the Nominatim usage policy (DropaSearchProvider.php:9). Proximity bias is implemented as a viewbox of ±2° around the coordinates with bounded=0 (prefer but do not restrict to the box).

⚠️ Warning: Nominatim is rate-limited by the OSM Foundation. The dropaSearch fallback is suitable for low-volume finders but should not be relied on for high-traffic production finders. Customers with significant search volume should configure a BYOK provider.

MappingController proxy flow

app/Http/Controllers/MappingController.php exposes two public endpoints:

MethodRouteAction
GET/api/mapping/autocompleteMappingController@autocomplete
GET/api/mapping/placesMappingController@places

Both routes carry throttle:30,1 middleware (30 requests per minute, global — not per user).

Request flow

  1. The widget sends finder_token alongside the user’s input.
  2. resolveProvider($finderToken) is called (MappingController.php:69-106).
  3. Provider slug cache lookup: Cache::remember('autocomplete_finder_' . $finderToken, 60, ...) checks for a cached [$providerSlug, $finderId] pair. On a miss, the factory looks up the Finder by token, reads settings.autocompleteProvider from the finder’s settings JSON, and caches the slug + finder ID for 60 seconds (MappingController.php:76-89). The plaintext API key is never cached.
  4. If the slug is dropaSearch (or the finder is not found), the call falls through to fallbackProvider().
  5. Otherwise, FinderIntegration::where('finder_id', $finderId)->where('provider', $providerSlug)->first()?->api_key is called fresh on every request to decrypt the customer key (MappingController.php:96-99).
  6. AutocompleteProviderFactory::make($providerSlug, $apiKey) constructs the provider instance.
  7. $provider->search() or $provider->placeDetails() is called and the result is returned as JSON.

If the API key row is missing or decryption fails, resolveProvider() silently falls back to DropaSearchProvider.

60-second provider-slug cache

The cache key autocomplete_finder_{token} stores only [$providerSlug, $finderId] — never the encrypted or plaintext API key. The 60-second TTL means that changing a finder’s autocomplete provider in the dashboard takes up to 60 seconds to propagate to live widget requests.

FinderIntegrationController::upsert and ::destroy both call Cache::forget('autocomplete_finder_' . $finder->token) on success (FinderIntegrationController.php:62, 87) to flush the cache immediately when a customer updates their integration.

BYOK key storage

Customer-provided API keys are stored in the finder_integrations table via FinderIntegration model:

// Write path — setter encrypts before storing public function setApiKeyAttribute(string $value): void { $this->attributes['encrypted_api_key'] = Crypt::encryptString($value); } // Read path — getter decrypts on access public function getApiKeyAttribute(): ?string { try { return Crypt::decryptString($this->attributes['encrypted_api_key'] ?? ''); } catch (DecryptException) { return null; } }

app/Models/FinderIntegration.php:23-40

The database column is encrypted_api_key. The model’s $fillable array lists encrypted_api_key, but callers always assign via the api_key virtual attribute (which triggers the setter) — not by writing the encrypted form directly. Laravel’s Crypt::encryptString uses AES-256-CBC keyed from APP_KEY.

The FinderIntegrationController API validates allowed providers at the controller level:

private const ALLOWED_PROVIDERS = ['google', 'radar', 'mapbox', 'here', 'geoapify'];

app/Http/Controllers/FinderIntegrationController.php:14

dropaSearch is intentionally excluded — it has no API key to store.

Server-side GOOGLE_API_KEY vs. customer BYOK

There are two distinct Google API key paths:

KeyEnv varUsed forStored where
Server-sideGOOGLE_API_KEYCSV geocoding in LocationController::geocodeLocation().env only — never exposed to widget callers
Customer BYOKStored in finder_integrations.encrypted_api_keyWidget autocomplete and place details via GoogleProviderDatabase, AES-256-CBC encrypted

The server-side key is used only during CSV import to geocode locations that lack coordinates (LocationController.php:649-685). It is read via config('services.google.api_key') and is not the key passed to GoogleProvider for widget autocomplete.

💡 Tip: If a customer reports that autocomplete works in some finders but falls back to DropaSearch in others, the most common cause is a missing or corrupt finder_integrations row. Verify the row exists, check has_key is true, and confirm APP_KEY has not been rotated since the key was encrypted.

Adding a new provider

See ../../how-to/add-autocomplete-provider.md for the step-by-step guide. At minimum, adding a provider requires:

  1. A new class implementing AutocompleteProviderInterface under app/Services/Autocomplete/
  2. A new match arm in AutocompleteProviderFactory::make()
  3. Adding the slug to ALLOWED_PROVIDERS in FinderIntegrationController
  4. Adding the slug to the finder settings schema (customer-visible provider picker)