giosh94mhz/geonames-bundle
Symfony bundle that integrates the GeoNames geographical database into your app, providing services and console commands to import and query countries, regions, and cities. Simplifies location lookups and geodata management in PHP projects.
This is a Symfony 2 bundle, not a Laravel package. It ships Doctrine ORM entities, Symfony DI service definitions, bundle registration hooks, and Symfony console commands. There is no service provider, no facade, no config/geonames.php bridge, and no expectation it will run inside a Laravel app. Treat it as a reference implementation you mine, not a dependency you composer require.
Where to look first, in this order:
composer.json — check PHP/Symfony/Doctrine version constraints. If they cap at PHP 5.x / Symfony 2.x, composer require will fail on any modern Laravel app. That settles the build-vs-port decision in under a minute.src/Entity/ — the schema is the real asset: country, admin1/admin2 (state/province), city/toponym, alternate names, timezone, hierarchy. This is what you port.src/Command/ — the importer commands and which GeoNames dump each consumes. Read the configure() methods carefully; the file names and options will not match what you guess.getCities, lookup by geonameid, autocomplete builder). This tells you what methods your Eloquent repository needs to mimic.Minimal path to a working proof-of-concept in Laravel:
php artisan make:migration create_geonames_tables
php artisan make:command ImportGeonames
Mirror the bundle's column names — most importantly keep geonameid as the primary key, and keep country_code, admin1_code, admin2_code, population, latitude, longitude, and timezone verbatim. That way any example SQL or query structure you lift from the bundle's docs still applies.
First use case to prove out: import a single country, not the planet.
cities5000 or a country-scoped city dump — do not touch allCountries.txt on day one).If that round-trips end-to-end, you understand the bundle's import shape; expanding to more countries is I/O and patience, not new design.
One importer command per dataset, not one mega-command. The bundle organizes imports roughly one command per GeoNames flat file (countries, admin1, admin2, cities/allCountries.txt, alternate names, hierarchy, timezones). Copy that shape: a single geonames:import Artisan command with --dataset= and --country= options, dispatching to per-dataset handlers. This gives you idempotent re-runs of a single file when one dataset goes stale, instead of reloading the world.
Stream, don't file_get_contents. GeoNames city dumps are hundreds of MB to multiple GB uncompressed. Use fopen / SplFileObject and process line-by-line, flushing to the DB in chunks (DB::table(...)->upsert($chunk, ['geonameid'], [...]) — upsert matters so re-runs are safe). Wrap the whole thing in a --batch= option so you can dial chunk size against your DB's max_allowed_packet.
Build the repository interface before you build the models. The single most valuable thing the bundle gives you architecturally is a narrow query surface in front of storage. Recreate that: define a GeoRepository contract (countryByCode, regionsByCountry, citiesByRegion, searchCities($term)) and bind an Eloquent implementation in AppServiceProvider. When someone later decides to swap to Google Places or Nominatim, it's a binding change, not a refactor of every controller that touches locations.
Denormalize the hierarchy. The bundle exposes the GeoNames hierarchy, and the temptation is to reproduce it literally as a self-referencing parent_id. Don't — recursive CTEs and N+1 eager-loads on geo endpoints are the classic performance sink. Instead, denormalize country_name, admin1_name, admin2_name onto the city row at import time. One indexed cities table with (country_code, admin1_code, population DESC) covers 90% of autocomplete and lookup cases without a join.
Autocomplete is a prefix-match problem, not an LIKE '%term%' problem. Index name with a normalized lowercase column and query WHERE name_lower LIKE 'term%' (prefix, not infix). For anything past a few hundred thousand rows, add a LIKE 'term%' composite index or offload to Meilisearch/Typesense. The bundle doesn't solve this for you; it just shows you the shape of the data you're searching.
Run the import as a queued/off-peak job, never synchronously in a deploy. Whether you use Artisan::queue() with chunked jobs or a cron'd Schedule entry, the import is a maintenance operation. On a fresh cities5000 import for a mid-size country you'll see minutes, not seconds; on the full dataset it's hours. Make it resumable (track the last geonameid processed) so a killed job doesn't cost you the whole run.
Seed timezone from geonames, not from the browser. The bundle's timezone data is a nice freebie: once you have a city, you have an IANA timezone. Resolve timezone from the imported city on user signup and store it on the user record. It's more reliable than browser-derived TZ and removes an entire class of DST bugs from scheduling features.
composer require will almost certainly fail. The version constraints are Symfony 2-era and pin old PHP. Don't burn a day fighting Composer's solver to install something you can't run — check composer.json first, accept the port, and move on.
The GeoNames dump format drifts. Files get columns added, feature codes get reclassified, and the alternateNames file is enormous and periodically restructured. Pin a snapshot date in a config value, record it on each import (an imports audit row with dataset, source date, and row count), and fail loudly if the header/column count doesn't match expectation. Silent schema drift — where the importer "succeeds" but the hierarchy is subtly wrong — will produce bad address matches weeks later.
Admin hierarchies are genuinely inconsistent in GeoNames. Some places have admin2, some don't; a country can have three admin levels in one region and one in another. Design your schema to tolerate NULL admin1/admin2 codes and never assume a fixed depth. Build dependent dropdowns defensively — "no regions returned" must be a valid UI state, not a null-deref.
Use geonameid as the primary key everywhere. Resist the urge to use an auto-increment surrogate and put geonameid in a secondary unique column. Keeping geonameid as PK makes upserts trivial (upsert on PK), makes relations cheap (foreign keys are already the natural ID), and keeps the door open for round-tripping to the public GeoNames API (geonameid is the API's lookup key). It also means any SQL example you copy from the bundle's docs just works.
Population is your ordering knob, and it's stale. The bundle schema has population and you should absolutely use it — ORDER BY population DESC is how you get "Springfield, IL" above "Springfield, VT" in autocomplete. But know that GeoNames population figures lag reality by years. Use them for relative ordering only, never for anything user-visible or a business rule.
CC-BY attribution is a licensing obligation, not a suggestion. GeoNames data under CC-BY requires attribution wherever it's surfaced. Put "Geographic data © GeoNames (CC-BY)" in your UI footer or about page, and document it in the repo. This is trivial to forget until a legal review asks.
The alternate-names dataset is seductive and expensive. It's the file that gives you "München" → "Munich" searchability, but it's several times larger than the city dump and will dominate your import time. Start without it. If you need multi-language search, add it later as a separate opt-in import feeding a dedicated city_alternate_names table indexed on (name_lower, city_geonameid) — not as extra columns on cities.
Debugging a bad lookup almost always traces to normalization. Store a name_lower computed at import time with a stable normalizer (ICU transliterator or a documented mb_strtolower + accent-strip pass). Reapplying a different normalization at query time is the number-one source of "the city exists but search finds nothing."
Extension point: keep the importer dataset-agnostic. The most useful thing you can borrow from the bundle's command layer is the dispatcher pattern — a map of dataset => handler. That's the seam to hang future data sources on (postal codes, country geometries, subdivisions) without touching the existing commands.
How can I help you explore Laravel packages today?