Backend — autocomplete providers
Cross-references:
- Third-party API credentials and outbound contract details:
../../integration-points/backend-to-third-party.md- Customer-facing provider setup guide:
../../../../integrations/autocomplete-providers.md
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
search()
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/jsonplaceDetails()→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}.jsonplaceDetails()→ returnsnull(coordinates are included insearch())
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/autocompleteplaceDetails()→ returnsnull
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/autocompleteplaceDetails()→ returnsnull
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/autocompleteplaceDetails()→ returnsnull
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()→ returnsnull
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
dropaSearchfallback 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:
| Method | Route | Action |
|---|---|---|
GET | /api/mapping/autocomplete | MappingController@autocomplete |
GET | /api/mapping/places | MappingController@places |
Both routes carry throttle:30,1 middleware (30 requests per minute, global — not per user).
Request flow
- The widget sends
finder_tokenalongside the user’s input. resolveProvider($finderToken)is called (MappingController.php:69-106).- Provider slug cache lookup:
Cache::remember('autocomplete_finder_' . $finderToken, 60, ...)checks for a cached[$providerSlug, $finderId]pair. On a miss, the factory looks up theFinderby token, readssettings.autocompleteProviderfrom 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. - If the slug is
dropaSearch(or the finder is not found), the call falls through tofallbackProvider(). - Otherwise,
FinderIntegration::where('finder_id', $finderId)->where('provider', $providerSlug)->first()?->api_keyis called fresh on every request to decrypt the customer key (MappingController.php:96-99). AutocompleteProviderFactory::make($providerSlug, $apiKey)constructs the provider instance.$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:
| Key | Env var | Used for | Stored where |
|---|---|---|---|
| Server-side | GOOGLE_API_KEY | CSV geocoding in LocationController::geocodeLocation() | .env only — never exposed to widget callers |
| Customer BYOK | Stored in finder_integrations.encrypted_api_key | Widget autocomplete and place details via GoogleProvider | Database, 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_integrationsrow. Verify the row exists, checkhas_keyis true, and confirmAPP_KEYhas 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:
- A new class implementing
AutocompleteProviderInterfaceunderapp/Services/Autocomplete/ - A new
matcharm inAutocompleteProviderFactory::make() - Adding the slug to
ALLOWED_PROVIDERSinFinderIntegrationController - Adding the slug to the finder settings schema (customer-visible provider picker)