epigra/trgeozones
Laravel package for Turkey's administrative geography. It provides migrations, Eloquent models, and ready-to-use seed data for provinces, districts, and neighborhoods, helping you add address selection, location filters, and region-based features to your app quickly.
Install via Composer:
composer require epigra/trgeozones
Then publish and run the package assets. Most Turkish geo packages ship migrations + seeders, so expect something like:
php artisan vendor:publish --tag=trgeozones-config
php artisan vendor:publish --tag=trgeozones-migrations
php artisan migrate
If the package separates its data into a seeder (common for IL/İLÇE/SEMT/MUHİT/MAHALLE/POSTA KODU datasets), run:
php artisan vendor:publish --tag=trgeozones-seeders
php artisan db:seed --class=TrGeoZonesSeeder
Where to look first: the package's config/trgeozones.php (table names, route registration, prefixes) and the src/Models directory — that is what you will actually interact with. Eloquent models are almost always namespaced under Epigra\TrGeoZones\Models (typical names: City, District, Neighborhood, PostalCode). Confirm exact names from the README before wiring anything to them.
First use case: a Turkish address form. A city <select> feeds a district <select>, which feeds a semt/mahalle selector, which optionally resolves a postal code — the classic "İl → İlçe → Mahalle → Posta Kodu" cascade used on checkout/registration/kargo forms. If the package registers routes, you may already have JSON endpoints at /{prefix}/cities (etc.) you can call with fetch; if not, you will build thin controllers over the models.
Sanity check the install:
use Epigra\TrGeoZones\Models\City;
City::where('name', 'İstanbul')->first()->districts()->count();
If that returns a number larger than zero, the seed ran and relationships work.
Cascading dropdowns via the package's own routes. If the package exposes endpoints, prefer them over hand-rolled controllers — it keeps you aligned with package updates:
async function loadDistricts(cityId) {
const r = await fetch(`/trgeozones/cities/${cityId}/districts`);
const districts = await r.json();
// render <option>s
}
If routes are not published by default, build a small GeoController that returns District::where('city_id', $id)->get(['id','name']). Cache these responses (see below) — they are effectively static reference data.
Storing references, not names. Persist city_id, district_id, neighborhood_id foreign keys in your addresses / users / orders tables. Store the resolved name too for historical accuracy (Turkish municipal boundaries get reorganised — a mahalle can be reassigned or merged), so a five-year-old order still shows the address as it was when placed:
$address->fill([
'city_id' => $data['city_id'],
'district_id' => $data['district_id'],
'neighborhood_id' => $data['neighborhood_id'],
'city_name' => City::find($data['city_id'])->name, // snapshot
'postal_code' => optional(PostalCode::where('neighborhood_id', $data['neighborhood_id'])->first())->code,
]);
Validation with exists rules. Because seeded IDs are stable, exists rules are safe and cheap:
$request->validate([
'city_id' => ['required', 'exists:cities,id'],
'district_id' => ['required', 'exists:districts,id'],
'neighborhood_id' => ['nullable', 'exists:neighborhoods,id'],
]);
For stronger guarantees, add a rule that the district actually belongs to the chosen city (a plain exists won't catch a mismatched (city, district) pair).
Caching and prefetching. The dataset is large but static — cache aggressively. Prefetch all cities once (there are only ~81) and cache districts per city with a long TTL:
$cities = Cache::rememberForever('tr.cities', fn () => City::orderBy('name')->get(['id','name']));
$districts = Cache::rememberForever("tr.districts.{$cityId}",
fn () => District::where('city_id', $cityId)->orderBy('name')->get(['id','name']));
Because forever never expires on its own, use Cache::forget from a deploy hook or a php artisan cache:clear after upgrading the package.
Search / autocomplete. For typeahead across semt/mahalle names, prefer a LIKE '%q%' over the neighborhood table with a join to district + city for context (mahalle names repeat heavily — "Merkez" exists in hundreds of districts), or a WHERE name LIKE 'q%' prefix search to keep it indexable. Use Laravel's where('name', 'like', $q.'%') rather than a raw query so the package's configured table names stay abstracted.
Integration with existing address models. Wrap the package models behind your own App\Models\City etc. that extend the package model. When the package renames a column or reorganises, you patch one file instead of fifty:
namespace App\Models;
class City extends \Epigra\TrGeoZones\Models\City
{
protected $table = 'cities';
}
Turkish characters and collation. The single biggest source of bugs. Make sure your connection is utf8mb4 with utf8mb4_unicode_ci (or utf8mb4_turkish_ci if you match the package's seed exactly). Mismatched collation between your users.city column and the package's cities.name column causes Illegal mix of collations errors on joins. Verify:
SHOW VARIABLES LIKE 'character_set_database';
SHOW CREATE TABLE cities;
Case folding on İ/ı. PHP's strtolower/strtoupper are locale-insensitive and mangle Turkish. İSTANBUL lowercased by generic code becomes i̇stanbul (with combining dot). For search normalisation use mb_strtolower($s, 'UTF-8') and a custom İ→i, I→ı mapping — or do the comparison in the database where MySQL's Turkish collation handles it.
Seeding is slow and data-heavy. The full İl/İlçe/Semt/Muhit/Mahalle/Posta Kodu set is tens of thousands of rows. Seeders can take minutes and blow PHP memory if written as one insert() per row. Run seeders with --force in deploy scripts and consider DB::disableQueryLog() + chunked inserts. Never seed on every deploy — gate it behind a check like if (City::count() === 0).
Route/config collisions. If the package auto-registers routes under a fixed prefix, it can collide with an existing /{locale}/... catch-all. Publish the config first and adjust the prefix, or disable route registration and build your own controllers.
Name duplication. Neighborhood (mahalle) names are not globally unique — they repeat across districts, and posta kodu is not 1:1 with mahalle either. Never look up a neighborhood by name alone. Always scope by district_id (and often city_id). If you write Neighborhood::where('name', 'Merkez')->first() you will silently attach users to the wrong district.
Boundary changes are real. Türkiye's büyükşehir reforms (2012, 2014) merged/abolished belde and köy-level units and reassigned many mahalle. If the package's dataset is a few years old, some new developments won't resolve. Before committing to the package, spot-check a handful of districts you know (e.g. recently developed areas of Ankara/İzmir) against e-Devlet or TKGM data. If missing, either fork the seeder or maintain a small "custom neighborhoods" overlay table in your app.
Snapshot address data on write. As noted above — do not trust a foreign key to reproduce an address years later. Persist the resolved name and posta kodu at write time.
Extension points to use, not fight. The models are plain Eloquent; add scopes, accessors, relationships (e.g. User::city()) on your own subclasses rather than modifying the package. If you need to add fields (e.g. lat/lng, bölge grouping), use a new migration on the package's tables or a separate side table joined by ID — do not edit published migrations, or migrate:fresh on another machine will drift.
Debugging checklist when data is missing:
php artisan tinker → City::count(); if 0, the seeder didn't run.$connection; ensure it matches your app.cities vs tr_cities vs iller).composer dump-autoload and re-publish migrations/stubs if the version bumped its asset tags.How can I help you explore Laravel packages today?