micro-module/geotools
Geotools is a Laravel package for geographic data and geospatial helpers. Work with coordinates, distances, proximity calculations, and location formatting through convenient PHP APIs that make adding map-aware features to your application simple.
Install the canonical package (the repo is thephpleague/geotools; the Composer name is league/geotools):
composer require league/geotools
It requires PHP 7.3+ and pulls in willdurand/geocoder (v4) plus Symfony Console/Process for its CLI and batch worker. There is no Laravel auto-discovery, service provider, facade, or config file — you wire it yourself.
Look first at these classes:
League\Geotools\Geotools — entry point/facade for distance, geocoder, batch, and vertex helpers.League\Geotools\Coordinate\Coordinate — validated [latitude, longitude] value object.League\Geotools\Distance\Distance — unit conversion and flat/great-circle/Vincenty calculations.The fastest first use case is a distance calculation, with no HTTP or API key required:
use League\Geotools\Geotools;
use League\Geotools\Coordinate\Coordinate;
use League\Geotools\Distance\Distance;
$geotools = new Geotools();
$paris = new Coordinate([48.8234055, 2.3072664]);
$lyon = new Coordinate([45.764043, 4.835659]);
$km = $geotools->distance()
->setFrom($paris)
->setTo($lyon)
->in(Distance::UNIT_KILOMETER)
->greatCircle(); // float, in kilometers because of in()
For address geocoding you also need a PSR-18 HTTP client and PSR-17 factory, because Geocoder v4 providers do not ship one:
composer require php-http/guzzle7-adapter nyholm/psr7
use Geocoder\ProviderAggregator;
use Geocoder\Provider\Nominatim\Nominatim;
use Http\Adapter\Guzzle7\Client as GuzzleAdapter;
use Nyholm\Psr7\Factory\Psr17Factory;
$http = new GuzzleAdapter(new \GuzzleHttp\Client(['timeout' => 5]));
$factory = new Psr17Factory();
$geocoder = new ProviderAggregator();
$geocoder->registerProvider(
Nominatim::withOpenStreetMapServer($http, $factory, 'MyApp/1.0 ([email protected])')
);
$collection = $geotools->geocoder()
->using($geocoder)
->geocode('Paris, France')
->get();
$coordinate = $collection->isEmpty() ? null : $collection->first()->getCoordinates();
Create a small service provider so controllers/jobs never construct Geotools or providers directly:
// app/Providers/GeotoolsServiceProvider.php
namespace App\Providers;
use Geocoder\Provider\Nominatim\Nominatim;
use Geocoder\ProviderAggregator;
use Http\Adapter\Guzzle7\Client as GuzzleAdapter;
use Illuminate\Support\ServiceProvider;
use League\Geotools\Geotools;
use Nyholm\Psr7\Factory\Psr17Factory;
class GeotoolsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Geotools::class, fn () => new Geotools());
$this->app->singleton(ProviderAggregator::class, function () {
$aggregator = new ProviderAggregator();
$http = new GuzzleAdapter(new \GuzzleHttp\Client(['timeout' => 5]));
$factory = new Psr17Factory();
$aggregator->registerProvider(
Nominatim::withOpenStreetMapServer(
$http,
$factory,
config('geotools.nominatim.user_agent')
)
);
return $aggregator;
});
}
}
// config/geotools.php
return [
'nominatim' => [
'user_agent' => env('GEOCODER_USER_AGENT', 'MyApp/1.0 ([email protected])'),
],
'cache_ttl' => 60 * 60 * 24 * 30,
];
Keep the third-party API behind your own class. This is your main extension point and makes testing trivial:
namespace App\Support;
use Geocoder\ProviderAggregator;
use League\Geotools\Coordinate\Coordinate;
use League\Geotools\Distance\Distance;
use League\Geotools\Geotools;
final class GeoService
{
public function __construct(
private Geotools $geotools,
private ProviderAggregator $geocoder,
) {}
public function distanceKm(Coordinate $from, Coordinate $to): float
{
return $this->geotools->distance()
->setFrom($from)
->setTo($to)
->in(Distance::UNIT_KILOMETER)
->greatCircle();
}
public function geocode(string $address): ?Coordinate
{
$key = 'geocode:'.sha1(mb_strtolower(trim($address)));
return \Cache::remember($key, config('geotools.cache_ttl'), function () use ($address) {
$result = $this->geotools->geocoder()
->using($this->geocoder)
->geocode($address)
->get();
if ($result->isEmpty()) {
return null;
}
$c = $result->first()->getCoordinates();
return new Coordinate([$c->getLatitude(), $c->getLongitude()]);
});
}
public function reverse(float $lat, float $lng): ?string
{
$result = $this->geotools->geocoder()
->using($this->geocoder)
->reverse($lat, $lng)
->get();
return $result->isEmpty() ? null : $result->first()->getStreetName();
}
}
Note that Coordinate is a value object; if you cache it directly, confirm serialization works in your cache driver. Caching the [lat, lng] array or returning Coordinate from the closure both work, but the latter is easier at call sites.
$distance = $geotools->distance()->setFrom($a)->setTo($b);
$distance->in(Distance::UNIT_METER)->flat(); // planar approximation, small areas only
$distance->in(Distance::UNIT_KILOMETER)->greatCircle(); // haversine, spherical
$distance->in(Distance::UNIT_MILE)->vincenty(); // ellipsoidal, slowest/most accurate
Every call to in() or flat() returns a value and the Distance object is stateful. Build a fresh distance() chain per calculation rather than reusing a Distance instance across requests or loop iterations.
$bearing = $geotools->vertex()
->setFrom($paris)
->setTo($lyon)
->initialBearing(); // degrees
This is useful for map arrow rotation, “as the crow flies” headings, and route previews. It uses great-circle math, not road routing.
Batch is convenient for dozens of addresses, but it still performs network I/O. Dispatch a job, chunk the input, and rate-limit the provider:
namespace App\Jobs;
use App\Support\GeoService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Support\Facades\RateLimiter;
class GeocodeAddresses implements ShouldQueue
{
use Dispatchable, Queueable;
public function __construct(public array $addresses) {}
public function handle(GeoService $geo): void
{
foreach (array_chunk($this->addresses, 25) as $chunk) {
foreach ($chunk as $address) {
RateLimiter::attempt('nominatim', 1, function () use ($geo, $address) {
$geo->geocode($address);
}, 1); // one per second
}
}
}
}
If you use Batch::parallel(), understand that it dispatches work to child processes via the CLI. That is often fine on a worker, but it is a poor fit for web requests and can fail where proc_open is disabled. Prefer queued chunked jobs unless you specifically need the batch result aggregation.
Use decimal(10,7) for latitude and decimal(10,7) for longitude (roughly centimeter precision), and cast to float in Eloquent:
protected $casts = [
'latitude' => 'float',
'longitude' => 'float',
];
For “find nearby” queries, do not load every row and call Distance in PHP. Add a bounding-box prefilter and use MySQL’s ST_Distance_Sphere (MySQL 5.7+/MariaDB) or PostGIS. Use Geotools’ distance methods for calculations already in PHP or for small candidate sets.
Because GeoService depends on Geocoder\Geocoder/ProviderAggregator, you can swap in a fake implementation in tests. Test the distance logic with fixed coordinates and never hit Nominatim in CI.
[latitude, longitude]. GeoJSON, Google Maps URLs, and many APIs are [longitude, latitude]. Passing [-73.98, 40.75] (NYC in GeoJSON order) is silently accepted as an odd but valid coordinate in some cases and throws in others. Always construct new Coordinate([$lat, $lng]) deliberately. Coordinate validates ranges (-90..90, -180..180), so out-of-range values throw InvalidArgumentException.flat() is not the earth. It uses a planar approximation and drifts quickly beyond a few kilometers. Use greatCircle() for general distance and vincenty() only when ellipsoidal accuracy matters.vincenty() can fail for near-antipodal points where the iterative solution does not converge. Have a fallback to greatCircle().Distance object is mutable/stateful. Code like $d = $geotools->distance()->setFrom($a)->setTo($b); reused across loop iterations will surprise you. Rebuild each time.php-http/guzzle7-adapter (or another PSR-18 adapter and PSR-17 factory), provider construction fails or requests fail with discovery errors. This is the most common installation problem.User-Agent with contact info, stay near one request per second, and cache aggressively. Google, Mapbox, and other providers require API keys and billing; put keys in config/services.php, not in package config.isEmpty(). $collection->first() can throw when no result is found, and rate-limit or quota responses may surface as exceptions rather than empty collections. Catch provider exceptions and decide whether to retry or return null.Batch::parallel() needs a working process environment. It spawns subprocesses; shared hosting, some containers, and disable_functions=proc_open setups will break it. Queues plus chunking are usually the safer Laravel answer.vendor/bin/geotools) is Symfony Console based. It is handy for one-off checks (distance:flat, geocoder:geocode, vertex:initial-bearing) but not necessary in production. Custom Artisan commands calling GeoService give you better logging and config reuse.$coordinate->setEllipsoid(...)) and keep it consistent across both points. Distances use the from-coordinate’s ellipsoid.Distance::UNIT_METER, UNIT_KILOMETER, UNIT_MILE, UNIT_FOOT, UNIT_NAUTICAL_MILE. Call in() before the algorithm method, otherwise you get the default unit (meters).null for a month if the address was merely temporarily un-geocodable; use a short TTL for misses and a long TTL for hits.GeoService is the seam for swapping providers, adding metrics, and faking results in tests.How can I help you explore Laravel packages today?