diff --git a/.github/workflows/provider.yml b/.github/workflows/provider.yml
index ea7b27fa7..d560704e5 100644
--- a/.github/workflows/provider.yml
+++ b/.github/workflows/provider.yml
@@ -45,6 +45,7 @@ jobs:
- MaxMindBinary
- Nominatim
- OpenCage
+ - OpenGeocoding
- OpenRouteService
- Pelias
- Photon
diff --git a/.github/workflows/subtree.yml b/.github/workflows/subtree.yml
index 577069128..a46467763 100644
--- a/.github/workflows/subtree.yml
+++ b/.github/workflows/subtree.yml
@@ -113,6 +113,7 @@ jobs:
- { folder: MaxMindBinary, repository: maxmind-binary-provider }
- { folder: Nominatim, repository: nominatim-provider }
- { folder: OpenCage, repository: open-cage-provider }
+ - { folder: OpenGeocoding, repository: opengeocoding-provider }
- { folder: OpenRouteService, repository: openrouteservice-provider }
- { folder: Pelias, repository: pelias-provider }
- { folder: Photon, repository: photon-provider }
diff --git a/README.md b/README.md
index 31b250196..3e0458c36 100644
--- a/README.md
+++ b/README.md
@@ -139,6 +139,7 @@ Provider | Package | Features | Stats
[MapTiler](https://github.com/geocoder-php/maptiler-provider) | `geocoder-php/maptiler-provider` | address, reverse
[Website](https://www.maptiler.com/cloud/geocoding/) | [](https://packagist.org/packages/geocoder-php/maptiler-provider)
[](https://packagist.org/packages/geocoder-php/maptiler-provider)
[Nominatim](https://github.com/geocoder-php/nominatim-provider)
(OpenStreetMap) | `geocoder-php/nominatim-provider` | address, reverse
[Website](http://wiki.openstreetmap.org/wiki/Nominatim) | [](https://packagist.org/packages/geocoder-php/nominatim-provider)
[](https://packagist.org/packages/geocoder-php/nominatim-provider)
[OpenCage](https://github.com/geocoder-php/open-cage-provider) | `geocoder-php/open-cage-provider` | address, reverse
[Website](https://opencagedata.com/) | [](https://packagist.org/packages/geocoder-php/open-cage-provider)
[](https://packagist.org/packages/geocoder-php/open-cage-provider)
+[OpenGeocoding](https://github.com/geocoder-php/opengeocoding-provider) | `geocoder-php/opengeocoding-provider` | address, reverse
[Website](https://opengeocoding.com/) | [](https://packagist.org/packages/geocoder-php/opengeocoding-provider)
[](https://packagist.org/packages/geocoder-php/opengeocoding-provider)
[OpenRouteService](https://github.com/geocoder-php/openrouteservice-provider) | `geocoder-php/openrouteservice-provider` | address, reverse
[Website](https://openrouteservice.org/) | [](https://packagist.org/packages/geocoder-php/openrouteservice-provider)
[](https://packagist.org/packages/geocoder-php/openrouteservice-provider)
[Pelias](https://github.com/geocoder-php/pelias-provider) | `geocoder-php/pelias-provider` | address, reverse
[Website](https://pelias.io/) | [](https://packagist.org/packages/geocoder-php/pelias-provider)
[](https://packagist.org/packages/geocoder-php/pelias-provider)
[Photon](https://github.com/geocoder-php/photon-provider) | `geocoder-php/photon-provider` | address, reverse
[Website](https://photon.komoot.de/) | [](https://packagist.org/packages/geocoder-php/photon-provider)
[](https://packagist.org/packages/geocoder-php/photon-provider)
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index d01fae0f5..49f21a177 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -48,6 +48,7 @@
+
diff --git a/src/Provider/OpenGeocoding/.gitattributes b/src/Provider/OpenGeocoding/.gitattributes
new file mode 100644
index 000000000..6643fc2fc
--- /dev/null
+++ b/src/Provider/OpenGeocoding/.gitattributes
@@ -0,0 +1,5 @@
+.gitattributes export-ignore
+.github/ export-ignore
+.gitignore export-ignore
+phpunit.xml.dist export-ignore
+Tests/ export-ignore
diff --git a/src/Provider/OpenGeocoding/.github/workflows/provider.yml b/src/Provider/OpenGeocoding/.github/workflows/provider.yml
new file mode 100644
index 000000000..74ab6830d
--- /dev/null
+++ b/src/Provider/OpenGeocoding/.github/workflows/provider.yml
@@ -0,0 +1,37 @@
+name: Provider
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+
+jobs:
+ test:
+ name: PHP ${{ matrix.php-version }} (${{ matrix.deps }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version: ['8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
+ deps: ['high']
+ include:
+ - php-version: '8.0'
+ deps: 'low'
+ steps:
+ - uses: actions/checkout@v7
+ - name: Use PHP ${{ matrix.php-version }}
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ extensions: curl
+ - name: Validate composer.json
+ run: composer validate --strict
+ - name: Install dependencies
+ if: ${{ matrix.deps == 'high' }}
+ run: composer update --prefer-stable --prefer-dist --no-progress
+ - name: Install dependencies (lowest)
+ if: ${{ matrix.deps == 'low' }}
+ run: composer update --prefer-lowest --prefer-stable --prefer-dist --no-progress
+ - name: Run test suite
+ run: composer run-script test
diff --git a/src/Provider/OpenGeocoding/.gitignore b/src/Provider/OpenGeocoding/.gitignore
new file mode 100644
index 000000000..c49a5d8df
--- /dev/null
+++ b/src/Provider/OpenGeocoding/.gitignore
@@ -0,0 +1,3 @@
+vendor/
+composer.lock
+phpunit.xml
diff --git a/src/Provider/OpenGeocoding/CHANGELOG.md b/src/Provider/OpenGeocoding/CHANGELOG.md
new file mode 100644
index 000000000..38d81cd6b
--- /dev/null
+++ b/src/Provider/OpenGeocoding/CHANGELOG.md
@@ -0,0 +1,9 @@
+# Change Log
+
+The change log describes what is "Added", "Removed", "Changed" or "Fixed" between each release.
+
+## 1.0.0
+
+### Added
+
+- First release: address search and reverse geocoding with the OpenGeocoding API, for PHP 8.0 to 8.5 and Geocoder PHP 4 and 5
diff --git a/src/Provider/OpenGeocoding/LICENSE b/src/Provider/OpenGeocoding/LICENSE
new file mode 100644
index 000000000..6d88d944a
--- /dev/null
+++ b/src/Provider/OpenGeocoding/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Théo Mathieu
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/src/Provider/OpenGeocoding/Model/OpenGeocodingAddress.php b/src/Provider/OpenGeocoding/Model/OpenGeocodingAddress.php
new file mode 100644
index 000000000..bd1ac5a47
--- /dev/null
+++ b/src/Provider/OpenGeocoding/Model/OpenGeocodingAddress.php
@@ -0,0 +1,87 @@
+displayName;
+ }
+
+ public function withDisplayName(?string $displayName): self
+ {
+ $new = clone $this;
+ $new->displayName = $displayName;
+
+ return $new;
+ }
+
+ /**
+ * False for a partial match, such as the street when the house number was not found.
+ */
+ public function isGoodMatch(): bool
+ {
+ return $this->goodMatch;
+ }
+
+ public function withGoodMatch(bool $goodMatch): self
+ {
+ $new = clone $this;
+ $new->goodMatch = $goodMatch;
+
+ return $new;
+ }
+
+ /**
+ * Distance in metres from the queried point; reverse geocoding only.
+ */
+ public function getDistance(): ?float
+ {
+ return $this->distance;
+ }
+
+ public function withDistance(?float $distance): self
+ {
+ $new = clone $this;
+ $new->distance = $distance;
+
+ return $new;
+ }
+
+ /**
+ * Reverse geocoding only: "address", "street" or "locality".
+ */
+ public function getLayer(): ?string
+ {
+ return $this->layer;
+ }
+
+ public function withLayer(?string $layer): self
+ {
+ $new = clone $this;
+ $new->layer = $layer;
+
+ return $new;
+ }
+}
diff --git a/src/Provider/OpenGeocoding/OpenGeocoding.php b/src/Provider/OpenGeocoding/OpenGeocoding.php
new file mode 100644
index 000000000..a24f5eb48
--- /dev/null
+++ b/src/Provider/OpenGeocoding/OpenGeocoding.php
@@ -0,0 +1,193 @@
+apiKey = $apiKey;
+ parent::__construct($client);
+ }
+
+ public function geocodeQuery(GeocodeQuery $query): Collection
+ {
+ $address = $query->getText();
+
+ if (filter_var($address, FILTER_VALIDATE_IP)) {
+ throw new UnsupportedOperation('The OpenGeocoding provider does not support IP addresses, only street addresses.');
+ }
+
+ $json = $this->executeQuery(self::API_URL.'/v1/search?'.http_build_query(['addressString' => $address]));
+
+ if ([] === $json) {
+ return new AddressCollection([]);
+ }
+
+ return new AddressCollection([$this->buildAddress($json)]);
+ }
+
+ public function reverseQuery(ReverseQuery $query): Collection
+ {
+ $coordinates = $query->getCoordinates();
+
+ $url = self::API_URL.'/v1/reverse?'.http_build_query([
+ 'lat' => $coordinates->getLatitude(),
+ 'lon' => $coordinates->getLongitude(),
+ 'size' => max(1, min(self::MAX_REVERSE_RESULTS, $query->getLimit())),
+ 'radiusM' => $query->getData('radiusM'),
+ ]);
+
+ $results = [];
+ foreach ($this->executeQuery($url)['results'] ?? [] as $result) {
+ $results[] = $this->buildAddress(
+ $result['address'] ?? [],
+ isset($result['distanceM']) ? (float) $result['distanceM'] : null,
+ $result['layer'] ?? null
+ );
+ }
+
+ return new AddressCollection($results);
+ }
+
+ public function getName(): string
+ {
+ return 'opengeocoding';
+ }
+
+ /**
+ * @return array
+ */
+ private function executeQuery(string $url): array
+ {
+ $request = $this->createRequest('GET', $url, [
+ 'Accept' => 'application/json',
+ 'x-api-key' => $this->apiKey,
+ ]);
+ $response = $this->getHttpClient()->sendRequest($request);
+
+ $status = $response->getStatusCode();
+ $json = json_decode((string) $response->getBody(), true);
+ $message = is_array($json) && isset($json['message']) && is_string($json['message']) ? $json['message'] : null;
+
+ if (401 === $status || 403 === $status) {
+ throw new InvalidCredentials($message ?? 'Invalid or missing API key.');
+ }
+ if (429 === $status) {
+ throw new QuotaExceeded($message ?? 'Rate limit or request credits exceeded.');
+ }
+ if (400 === $status && null !== $message) {
+ throw new InvalidArgument($message);
+ }
+ if (200 !== $status || !is_array($json)) {
+ throw InvalidServerResponse::create($url, $status);
+ }
+
+ return $json;
+ }
+
+ /**
+ * @param array $item a FullAddress object
+ */
+ private function buildAddress(array $item, ?float $distance = null, ?string $layer = null): OpenGeocodingAddress
+ {
+ $coordinates = $item['geoCoordinates'] ?? [];
+ $details = $item['addressDetails'] ?? [];
+
+ $builder = new AddressBuilder($this->getName());
+ $builder->setCoordinates((float) ($coordinates['lat'] ?? 0), (float) ($coordinates['lon'] ?? 0));
+ if (isset($coordinates['boundingBox'])) {
+ $box = $coordinates['boundingBox'];
+ $builder->setBounds(
+ (float) ($box['minLat'] ?? 0),
+ (float) ($box['minLon'] ?? 0),
+ (float) ($box['maxLat'] ?? 0),
+ (float) ($box['maxLon'] ?? 0)
+ );
+ }
+
+ $builder->setStreetNumber($details['houseNumber'] ?? null);
+ $builder->setStreetName($details['road'] ?? null);
+ $builder->setPostalCode($details['postcode'] ?? null);
+ $builder->setLocality($details['city'] ?? $details['town'] ?? $details['village'] ?? $details['hamlet'] ?? $details['municipality'] ?? null);
+ $builder->setSubLocality($details['suburb'] ?? $details['borough'] ?? $details['cityDistrict'] ?? $details['neighbourhood'] ?? null);
+ $builder->setCountry($details['country'] ?? null);
+ $builder->setCountryCode(isset($details['countryCode']) ? strtoupper($details['countryCode']) : null);
+ $this->addAdminLevels($builder, $details);
+
+ /** @var OpenGeocodingAddress $address */
+ $address = $builder->build(OpenGeocodingAddress::class);
+
+ return $address
+ ->withDisplayName($item['displayName'] ?? null)
+ ->withGoodMatch((bool) ($item['goodMatch'] ?? false))
+ ->withDistance($distance)
+ ->withLayer($layer);
+ }
+
+ /**
+ * @param array $details an AddressDetails object
+ */
+ private function addAdminLevels(AddressBuilder $builder, array $details): void
+ {
+ $regions = array_values(array_filter(
+ $details['regions'] ?? [],
+ fn ($region) => isset($region['enName']) || isset($region['localName'])
+ ));
+ usort($regions, fn ($a, $b) => ($a['adminLevel'] ?? 0) <=> ($b['adminLevel'] ?? 0));
+
+ // Geocoder's AdminLevelCollection only accepts levels 1 to 5.
+ foreach (array_slice($regions, 0, 5) as $i => $region) {
+ $builder->addAdminLevel($i + 1, $region['enName'] ?? $region['localName'], $region['adminCode'] ?? null);
+ }
+
+ if ([] !== $regions) {
+ return;
+ }
+
+ $level = 1;
+ foreach (['state', 'county'] as $field) {
+ if (isset($details[$field]) && '' !== $details[$field]) {
+ $builder->addAdminLevel($level++, $details[$field]);
+ }
+ }
+ }
+}
diff --git a/src/Provider/OpenGeocoding/README.md b/src/Provider/OpenGeocoding/README.md
new file mode 100644
index 000000000..8519545a8
--- /dev/null
+++ b/src/Provider/OpenGeocoding/README.md
@@ -0,0 +1,110 @@
+# OpenGeocoding provider for Geocoder PHP
+
+[](https://github.com/geocoder-php/opengeocoding-provider/actions/workflows/provider.yml)
+[](https://packagist.org/packages/geocoder-php/opengeocoding-provider)
+[](https://packagist.org/packages/geocoder-php/opengeocoding-provider)
+[](LICENSE)
+
+A [Geocoder PHP](https://github.com/geocoder-php/Geocoder) provider for
+[OpenGeocoding](https://opengeocoding.com), a hosted geocoding API built on open data
+(OpenStreetMap, Overture Maps, OpenAddresses, GeoNames). The engine is
+[source-available](https://github.com/Mokto/opengeocoding).
+
+This is a **READ ONLY** repository. Contributions and issues belong in the
+[main Geocoder repository](https://github.com/geocoder-php/Geocoder).
+
+## Install
+
+You need a [PSR-18 HTTP client](https://packagist.org/providers/psr/http-client-implementation), for example Guzzle:
+
+```bash
+composer require geocoder-php/opengeocoding-provider guzzlehttp/guzzle
+```
+
+If migrating from `opengeocoding/opengeocoding-provider`, remove that package first.
+The PHP namespaces and API are unchanged; do not install both packages together.
+
+Create an API key in the [OpenGeocoding dashboard](https://opengeocoding.com/dashboard). The
+provider sends it in the `x-api-key` header, never in the URL.
+
+## Usage
+
+```php
+use Geocoder\Provider\OpenGeocoding\OpenGeocoding;
+use Geocoder\Query\GeocodeQuery;
+use Geocoder\Query\ReverseQuery;
+use Http\Discovery\Psr18Client;
+
+$provider = new OpenGeocoding(new Psr18Client(), getenv('OPENGEOCODING_API_KEY'));
+
+$address = $provider->geocodeQuery(GeocodeQuery::create('10 Rue de Rivoli, Paris'))->first();
+echo $address->getCoordinates()->getLatitude(); // 48.8555
+echo $address->getPostalCode(); // 75004
+echo $address->getDisplayName(); // 10, Rue de Rivoli, Paris, Ile-de-France, France
+
+$results = $provider->reverseQuery(ReverseQuery::fromCoordinates(55.676, 12.568)->withLimit(3));
+foreach ($results as $address) {
+ printf("%s (%.0f m)\n", $address->getDisplayName(), $address->getDistance());
+}
+```
+
+The provider works with the rest of Geocoder PHP, such as `StatefulGeocoder`, `ProviderAggregator`,
+the `Chain` provider and the cache plugin.
+
+### Behaviour
+
+- **Search returns at most one result**, the best match, whatever the query's limit. An address
+ that matches nothing returns an empty collection.
+- **Reverse** returns the nearest results first, up to the query's limit (at most 10). Pass a search
+ radius in metres with `->withData('radiusM', 200)` (the API accepts up to 2000 and defaults to 500).
+- **The locale is ignored**: the API has no language parameter.
+- IP addresses are not supported (`UnsupportedOperation`).
+- Admin levels come from the address's regions, ordered from the largest; their codes are the
+ ISO 3166-2 subdivision codes (`IDF` for Île-de-France). Country codes are upper case.
+- Results are `OpenGeocodingAddress` objects, with `getDisplayName()`, `isGoodMatch()` (false for a
+ partial match, such as the street when the house number was not found) and, for reverse
+ results, `getDistance()` in metres and `getLayer()` (`address`, `street` or `locality`).
+
+### Errors
+
+| HTTP status | Exception |
+|---|---|
+| 401, 403 | `Geocoder\Exception\InvalidCredentials` |
+| 429 (rate limit or no credits left) | `Geocoder\Exception\QuotaExceeded` |
+| 400 | `Geocoder\Exception\InvalidArgument`, with the API's message |
+| anything else | `Geocoder\Exception\InvalidServerResponse` |
+
+## Pricing
+
+Plans and request limits are on the [pricing page](https://opengeocoding.com/pricing).
+
+## Data attribution
+
+Results come from open data under the ODbL and other open-data licences. When you show results,
+or data derived from them, to others, credit the sources, for example:
+
+> Data © OpenStreetMap contributors (ODbL 1.0), Overture Maps Foundation, OpenAddresses contributors, GeoNames (CC BY 4.0, modified). Details: https://opengeocoding.com/legal/attributions
+
+Some countries' sources need their own credit line; see
+[the data attributions page](https://opengeocoding.com/legal/attributions).
+
+## Development
+
+From `src/Provider/OpenGeocoding` in the main Geocoder repository:
+
+```bash
+composer update
+composer test
+```
+
+The suite includes unit tests and the shared Geocoder integration tests, using cached
+responses. Run `composer analyse` and `composer cs` from the main repository's root.
+
+The tests replay the responses in `Tests/.cached_responses`. To record a new one, delete the
+matching file and run the tests with `OPENGEOCODING_API_KEY` set. The key is sent in a header,
+so it never reaches the cached files. Check that a recorded file holds a real result, not an
+error, before you commit it.
+
+## License
+
+MIT. See [LICENSE](LICENSE).
diff --git a/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_2342a693e453d429808f665b5b814d7a37d9b6d5 b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_2342a693e453d429808f665b5b814d7a37d9b6d5
new file mode 100644
index 000000000..6fb4c986a
--- /dev/null
+++ b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_2342a693e453d429808f665b5b814d7a37d9b6d5
@@ -0,0 +1 @@
+s:3498:"{"results":[{"address":{"displayName":"800, 16th Street Northwest, Washington, District of Columbia, United States","geoCoordinates":{"lat":38.900513,"lon":-77.036995,"boundingBox":{"minLat":38.899513,"maxLat":38.901512,"minLon":-77.03799,"maxLon":-77.03599}},"addressDetails":{"country":"United States","countryCode":"us","state":"District of Columbia","regionCodes":[{"adminLevel":4,"code":"US-DC"}],"city":"Washington","road":"16th Street Northwest","houseNumber":"800","postcode":"20006","regions":[{"countryCode":"us","countryName":"United States","adminCode":"DC","adminLevel":4,"localName":"District of Columbia","enName":"District of Columbia"}]},"goodMatch":true},"distanceM":34.02458,"layer":"address"},{"address":{"displayName":"1615, H Street Northwest, Washington, District of Columbia, United States","geoCoordinates":{"lat":38.90059,"lon":-77.03759,"boundingBox":{"minLat":38.89959,"maxLat":38.90159,"minLon":-77.03859,"maxLon":-77.03659}},"addressDetails":{"country":"United States","countryCode":"us","state":"District of Columbia","regionCodes":[{"adminLevel":4,"code":"US-DC"}],"city":"Washington","road":"H Street Northwest","houseNumber":"1615","postcode":"20062","regions":[{"countryCode":"us","countryName":"United States","adminCode":"DC","adminLevel":4,"localName":"District of Columbia","enName":"District of Columbia"}]},"goodMatch":true},"distanceM":67.31728,"layer":"address"},{"address":{"displayName":"1530, H Street Northwest, Washington, District of Columbia, United States","geoCoordinates":{"lat":38.8997,"lon":-77.03654,"boundingBox":{"minLat":38.8987,"maxLat":38.9007,"minLon":-77.03754,"maxLon":-77.03554}},"addressDetails":{"country":"United States","countryCode":"us","state":"District of Columbia","regionCodes":[{"adminLevel":4,"code":"US-DC"}],"city":"Washington","road":"H Street Northwest","houseNumber":"1530","postcode":"20005","regions":[{"countryCode":"us","countryName":"United States","adminCode":"DC","adminLevel":4,"localName":"District of Columbia","enName":"District of Columbia"}]},"goodMatch":true},"distanceM":68.559105,"layer":"address"},{"address":{"displayName":"850, 16th Street Northwest, Washington, District of Columbia, United States","geoCoordinates":{"lat":38.900833,"lon":-77.03688,"boundingBox":{"minLat":38.899834,"maxLat":38.901833,"minLon":-77.03788,"maxLon":-77.03588}},"addressDetails":{"country":"United States","countryCode":"us","state":"District of Columbia","regionCodes":[{"adminLevel":4,"code":"US-DC"}],"city":"Washington","road":"16th Street Northwest","houseNumber":"850","postcode":"20006","regions":[{"countryCode":"us","countryName":"United States","adminCode":"DC","adminLevel":4,"localName":"District of Columbia","enName":"District of Columbia"}]},"goodMatch":true},"distanceM":70.26911,"layer":"address"},{"address":{"displayName":"888, 16th Street Northwest, Washington, District of Columbia, United States","geoCoordinates":{"lat":38.900906,"lon":-77.03688,"boundingBox":{"minLat":38.899906,"maxLat":38.901905,"minLon":-77.03788,"maxLon":-77.03588}},"addressDetails":{"country":"United States","countryCode":"us","state":"District of Columbia","regionCodes":[{"adminLevel":4,"code":"US-DC"}],"city":"Washington","road":"16th Street Northwest","houseNumber":"888","postcode":"20006","regions":[{"countryCode":"us","countryName":"United States","adminCode":"DC","adminLevel":4,"localName":"District of Columbia","enName":"District of Columbia"}]},"goodMatch":true},"distanceM":78.00364,"layer":"address"}]}";
\ No newline at end of file
diff --git a/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_3729604a85cb6dfb1c61f5c2a4d1d01156494050 b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_3729604a85cb6dfb1c61f5c2a4d1d01156494050
new file mode 100644
index 000000000..734208d63
--- /dev/null
+++ b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_3729604a85cb6dfb1c61f5c2a4d1d01156494050
@@ -0,0 +1 @@
+s:618:"{"displayName":"10, Downing Street, London, Westminster, England, United Kingdom","geoCoordinates":{"lat":51.503468,"lon":-0.1278019,"boundingBox":{"minLat":51.50247,"maxLat":51.504467,"minLon":-0.1288019,"maxLon":-0.1268019}},"addressDetails":{"country":"United Kingdom","countryCode":"gb","state":"England","county":"Westminster","regionCodes":[{"adminLevel":4,"code":"GB-ENG"}],"city":"London","road":"Downing Street","houseNumber":"10","postcode":"SW1A 2AA","regions":[{"countryCode":"gb","countryName":"United Kingdom","adminCode":"ENG","adminLevel":4,"localName":"England","enName":"England"}]},"goodMatch":true}";
\ No newline at end of file
diff --git a/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_3e994a3c06eb8876a27daf5185afe78c2768f177 b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_3e994a3c06eb8876a27daf5185afe78c2768f177
new file mode 100644
index 000000000..56313860d
--- /dev/null
+++ b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_3e994a3c06eb8876a27daf5185afe78c2768f177
@@ -0,0 +1 @@
+s:782:"{"displayName":"10, Rue de Rivoli, Paris, Ile-de-France, France","geoCoordinates":{"lat":48.8555,"lon":2.3604083,"boundingBox":{"minLat":48.8545,"maxLat":48.8565,"minLon":2.3594084,"maxLon":2.3614082}},"addressDetails":{"country":"France","countryCode":"fr","state":"Ile-de-France","county":"Paris","regionCodes":[{"adminLevel":4,"code":"FR-IDF"},{"adminLevel":6,"code":"FR-75C"}],"municipality":"Paris","city":"Paris","road":"Rue de Rivoli","houseNumber":"10","postcode":"75004","regions":[{"countryCode":"fr","countryName":"France","adminCode":"IDF","adminLevel":4,"localName":"Île-de-France","enName":"Ile-de-France"},{"countryCode":"fr","countryName":"France","adminCode":"75C","adminLevel":6,"localName":"Paris","enName":"Paris","parentAdminCode":"FR-IDF"}]},"goodMatch":true}";
\ No newline at end of file
diff --git a/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_59c333369254491759617b6f25f5914eee2954e6 b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_59c333369254491759617b6f25f5914eee2954e6
new file mode 100644
index 000000000..d4e1ff0a8
--- /dev/null
+++ b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_59c333369254491759617b6f25f5914eee2954e6
@@ -0,0 +1 @@
+s:2:"{}";
\ No newline at end of file
diff --git a/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_5d4075750ece2b3a336bbc42e0eac0e5fd20f5d9 b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_5d4075750ece2b3a336bbc42e0eac0e5fd20f5d9
new file mode 100644
index 000000000..33a3db858
--- /dev/null
+++ b/src/Provider/OpenGeocoding/Tests/.cached_responses/api.opengeocoding.com_5d4075750ece2b3a336bbc42e0eac0e5fd20f5d9
@@ -0,0 +1 @@
+s:1478:"{"results":[{"address":{"displayName":"3, Rådhuspladsen, Copenhagen, Copenhagen Municipality, Capital Region of Denmark, Denmark","geoCoordinates":{"lat":55.67611,"lon":12.568231,"boundingBox":{"minLat":55.67511,"maxLat":55.67711,"minLon":12.56723,"maxLon":12.569231}},"addressDetails":{"country":"Denmark","countryCode":"dk","state":"Capital Region of Denmark","county":"Copenhagen Municipality","regionCodes":[{"adminLevel":4,"code":"DK-84"}],"city":"Copenhagen","road":"Rådhuspladsen","houseNumber":"3","postcode":"1550","regions":[{"countryCode":"dk","countryName":"Denmark","adminCode":"84","adminLevel":4,"localName":"Region Hovedstaden","enName":"Capital Region of Denmark"}]},"goodMatch":true},"distanceM":19.04121,"layer":"address"},{"address":{"displayName":"5, Rådhuspladsen, Copenhagen, Copenhagen Municipality, Capital Region of Denmark, Denmark","geoCoordinates":{"lat":55.67619,"lon":12.568038,"boundingBox":{"minLat":55.67519,"maxLat":55.67719,"minLon":12.567037,"maxLon":12.569037}},"addressDetails":{"country":"Denmark","countryCode":"dk","state":"Capital Region of Denmark","county":"Copenhagen Municipality","regionCodes":[{"adminLevel":4,"code":"DK-84"}],"city":"Copenhagen","road":"Rådhuspladsen","houseNumber":"5","postcode":"1550","regions":[{"countryCode":"dk","countryName":"Denmark","adminCode":"84","adminLevel":4,"localName":"Region Hovedstaden","enName":"Capital Region of Denmark"}]},"goodMatch":true},"distanceM":21.32454,"layer":"address"}]}";
\ No newline at end of file
diff --git a/src/Provider/OpenGeocoding/Tests/IntegrationTest.php b/src/Provider/OpenGeocoding/Tests/IntegrationTest.php
new file mode 100644
index 000000000..7bd4f2107
--- /dev/null
+++ b/src/Provider/OpenGeocoding/Tests/IntegrationTest.php
@@ -0,0 +1,43 @@
+ 'The API returns a placeholder result at 0,0',
+ ];
+
+ protected bool $testIpv4 = false;
+
+ protected bool $testIpv6 = false;
+
+ protected function createProvider(ClientInterface $httpClient)
+ {
+ return new OpenGeocoding($httpClient, $this->getApiKey());
+ }
+
+ protected function getCacheDir(): string
+ {
+ return __DIR__.'/.cached_responses';
+ }
+
+ protected function getApiKey(): string
+ {
+ return (string) getenv('OPENGEOCODING_API_KEY');
+ }
+}
diff --git a/src/Provider/OpenGeocoding/Tests/OpenGeocodingTest.php b/src/Provider/OpenGeocoding/Tests/OpenGeocodingTest.php
new file mode 100644
index 000000000..4d6f2fb10
--- /dev/null
+++ b/src/Provider/OpenGeocoding/Tests/OpenGeocodingTest.php
@@ -0,0 +1,198 @@
+getMockedHttpClient(), 'api_key');
+ $this->assertEquals('opengeocoding', $provider->getName());
+ }
+
+ public function testEmptyApiKey(): void
+ {
+ $this->expectException(InvalidCredentials::class);
+ new OpenGeocoding($this->getMockedHttpClient(), '');
+ }
+
+ public function testEmptySearchResponseIsAnEmptyCollection(): void
+ {
+ $provider = new OpenGeocoding($this->getMockedHttpClient('{}'), 'api_key');
+ $this->assertCount(0, $provider->geocodeQuery(GeocodeQuery::create('foobar')));
+ }
+
+ public function testEmptyReverseResponseIsAnEmptyCollection(): void
+ {
+ $provider = new OpenGeocoding($this->getMockedHttpClient('{}'), 'api_key');
+ $this->assertCount(0, $provider->reverseQuery(ReverseQuery::fromCoordinates(1, 2)));
+ }
+
+ public function testSearchRequest(): void
+ {
+ $client = $this->getMockedHttpClientCallback(function (RequestInterface $request) {
+ $this->assertSame('GET', $request->getMethod());
+ $this->assertSame('https://api.opengeocoding.com/v1/search?addressString=10+Rue+de+Rivoli%2C+Paris', (string) $request->getUri());
+ $this->assertSame('secret_key', $request->getHeaderLine('x-api-key'));
+
+ return '{}';
+ });
+
+ $provider = new OpenGeocoding($client, 'secret_key');
+ $provider->geocodeQuery(GeocodeQuery::create('10 Rue de Rivoli, Paris')->withLimit(3)->withLocale('fr'));
+ }
+
+ public function testReverseRequestCapsSizeAndPassesRadius(): void
+ {
+ $client = $this->getMockedHttpClientCallback(function (RequestInterface $request) {
+ $this->assertSame('https://api.opengeocoding.com/v1/reverse?lat=55.676&lon=12.568&size=10&radiusM=200', (string) $request->getUri());
+ $this->assertSame('secret_key', $request->getHeaderLine('x-api-key'));
+
+ return '{"results":[]}';
+ });
+
+ $provider = new OpenGeocoding($client, 'secret_key');
+ $provider->reverseQuery(ReverseQuery::fromCoordinates(55.676, 12.568)->withLimit(50)->withData('radiusM', 200));
+ }
+
+ public function testAdminLevelsFallBackToStateAndCounty(): void
+ {
+ $body = '{"displayName":"Somewhere","geoCoordinates":{"lat":1.5,"lon":2.5},"addressDetails":{"state":"A State","county":"A County","town":"A Town","countryCode":"xx"}}';
+ $provider = new OpenGeocoding($this->getMockedHttpClient($body), 'api_key');
+
+ /** @var OpenGeocodingAddress $result */
+ $result = $provider->geocodeQuery(GeocodeQuery::create('somewhere'))->first();
+ $this->assertNull($result->getBounds());
+ $this->assertEquals('A Town', $result->getLocality());
+ $this->assertEquals('XX', $result->getCountry()->getCode());
+ $this->assertCount(2, $result->getAdminLevels());
+ $this->assertEquals('A State', $result->getAdminLevels()->get(1)->getName());
+ $this->assertEquals('A County', $result->getAdminLevels()->get(2)->getName());
+ $this->assertFalse($result->isGoodMatch());
+ }
+
+ public function testIpAddressesAreUnsupported(): void
+ {
+ $this->expectException(UnsupportedOperation::class);
+ $provider = new OpenGeocoding($this->getMockedHttpClient(), 'api_key');
+ $provider->geocodeQuery(GeocodeQuery::create('127.0.0.1'));
+ }
+
+ /**
+ * @return array, string|null}>
+ */
+ public function errorProvider(): array
+ {
+ return [
+ '401' => [401, '{"code":"unauthenticated","message":"The API key is invalid, expired or revoked."}', InvalidCredentials::class, 'The API key is invalid, expired or revoked.'],
+ '403' => [403, '{"code":"permission_denied","message":"Origin not allowed."}', InvalidCredentials::class, 'Origin not allowed.'],
+ '429' => [429, '{"code":"resource_exhausted","message":"Too many requests per second for your plan."}', QuotaExceeded::class, 'Too many requests per second for your plan.'],
+ '400' => [400, '{"code":"invalid_argument","message":"addressString cannot be blank"}', InvalidArgument::class, 'addressString cannot be blank'],
+ '400 without API error' => [400, '', InvalidServerResponse::class, null],
+ '404' => [404, '{"code":"not_found","message":"Not found."}', InvalidServerResponse::class, null],
+ '503' => [503, '{"code":"unavailable","message":"Unavailable."}', InvalidServerResponse::class, null],
+ '200 not JSON' => [200, '', InvalidServerResponse::class, null],
+ ];
+ }
+
+ /**
+ * @dataProvider errorProvider
+ *
+ * @param class-string<\Throwable> $exception
+ */
+ public function testErrors(int $status, string $body, string $exception, ?string $message): void
+ {
+ $client = $this->getMockedHttpClientCallback(fn () => new Response($status, [], $body));
+ $provider = new OpenGeocoding($client, 'api_key');
+
+ $this->expectException($exception);
+ if (null !== $message) {
+ $this->expectExceptionMessage($message);
+ }
+ $provider->geocodeQuery(GeocodeQuery::create('foo'));
+ }
+
+ public function testGeocodeWithRealAddress(): void
+ {
+ $provider = new OpenGeocoding($this->getHttpClient($this->apiKey()), $this->apiKey());
+ $results = $provider->geocodeQuery(GeocodeQuery::create('10 Rue de Rivoli, Paris')->withLimit(5));
+
+ $this->assertCount(1, $results);
+
+ /** @var OpenGeocodingAddress $result */
+ $result = $results->first();
+ $this->assertInstanceOf(OpenGeocodingAddress::class, $result);
+ $this->assertEqualsWithDelta(48.8555, $result->getCoordinates()->getLatitude(), 0.001);
+ $this->assertEqualsWithDelta(2.3604, $result->getCoordinates()->getLongitude(), 0.001);
+ $this->assertNotNull($result->getBounds());
+ $this->assertLessThan($result->getBounds()->getNorth(), $result->getBounds()->getSouth());
+ $this->assertLessThan($result->getBounds()->getEast(), $result->getBounds()->getWest());
+ $this->assertEquals('10', $result->getStreetNumber());
+ $this->assertEquals('Rue de Rivoli', $result->getStreetName());
+ $this->assertEquals('75004', $result->getPostalCode());
+ $this->assertEquals('Paris', $result->getLocality());
+ $this->assertCount(2, $result->getAdminLevels());
+ $this->assertEquals('Ile-de-France', $result->getAdminLevels()->get(1)->getName());
+ $this->assertEquals('IDF', $result->getAdminLevels()->get(1)->getCode());
+ $this->assertEquals('Paris', $result->getAdminLevels()->get(2)->getName());
+ $this->assertEquals('France', $result->getCountry()->getName());
+ $this->assertEquals('FR', $result->getCountry()->getCode());
+ $this->assertEquals('10, Rue de Rivoli, Paris, Ile-de-France, France', $result->getDisplayName());
+ $this->assertTrue($result->isGoodMatch());
+ $this->assertNull($result->getDistance());
+ }
+
+ public function testReverseWithRealCoordinates(): void
+ {
+ $provider = new OpenGeocoding($this->getHttpClient($this->apiKey()), $this->apiKey());
+ $results = $provider->reverseQuery(ReverseQuery::fromCoordinates(55.676, 12.568)->withLimit(2));
+
+ $this->assertCount(2, $results);
+
+ /** @var OpenGeocodingAddress $result */
+ $result = $results->first();
+ $this->assertEqualsWithDelta(55.676, $result->getCoordinates()->getLatitude(), 0.001);
+ $this->assertEqualsWithDelta(12.568, $result->getCoordinates()->getLongitude(), 0.001);
+ $this->assertEquals('Rådhuspladsen', $result->getStreetName());
+ $this->assertEquals('1550', $result->getPostalCode());
+ $this->assertEquals('Copenhagen', $result->getLocality());
+ $this->assertEquals('Capital Region of Denmark', $result->getAdminLevels()->get(1)->getName());
+ $this->assertEquals('DK', $result->getCountry()->getCode());
+ $this->assertEquals('address', $result->getLayer());
+ $this->assertNotNull($result->getDistance());
+ $this->assertLessThan(100, $result->getDistance());
+ }
+}
diff --git a/src/Provider/OpenGeocoding/composer.json b/src/Provider/OpenGeocoding/composer.json
new file mode 100644
index 000000000..1d23b90ae
--- /dev/null
+++ b/src/Provider/OpenGeocoding/composer.json
@@ -0,0 +1,46 @@
+{
+ "name": "geocoder-php/opengeocoding-provider",
+ "type": "library",
+ "description": "Geocoder OpenGeocoding adapter",
+ "keywords": [
+ "geocoder",
+ "geocoding",
+ "reverse geocoding",
+ "openstreetmap",
+ "opengeocoding"
+ ],
+ "homepage": "https://opengeocoding.com",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Théo Mathieu",
+ "homepage": "https://github.com/Mokto"
+ }
+ ],
+ "require": {
+ "php": "^8.0",
+ "geocoder-php/common-http": "^4.6",
+ "willdurand/geocoder": "^4.0|^5.0"
+ },
+ "provide": {
+ "geocoder-php/provider-implementation": "1.0"
+ },
+ "require-dev": {
+ "geocoder-php/provider-integration-tests": "^1.6.3",
+ "php-http/message": "^1.0",
+ "phpunit/phpunit": "^9.6.11"
+ },
+ "autoload": {
+ "psr-4": {
+ "Geocoder\\Provider\\OpenGeocoding\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "scripts": {
+ "test": "vendor/bin/phpunit"
+ }
+}
diff --git a/src/Provider/OpenGeocoding/phpunit.xml.dist b/src/Provider/OpenGeocoding/phpunit.xml.dist
new file mode 100644
index 000000000..f400b474d
--- /dev/null
+++ b/src/Provider/OpenGeocoding/phpunit.xml.dist
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+ ./Tests/
+
+
+