siberfx / turkiye-address
Laravel package for seeding Turkish address (city, district, neighborhood) tables from SQL dumps.
Requires
- php: ^8.3
- illuminate/console: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.5 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A Laravel package that ships the complete administrative address hierarchy of Türkiye — 81 cities (il), 973 districts (ilçe) and 94,933 settlements (33k mahalle, 18k köy, 43k bağlı such as mevkii and mezra) — together with migrations, a fast seeder and Eloquent models with relationships.
- Works out of the box: migrations auto-load and the seeder reads the bundled SQL dumps, so nothing has to be published.
- Database-agnostic seeder, tested in CI on MySQL, PostgreSQL and SQLite: chunked upserts inside a single transaction, with foreign key checks left on.
- Mahalle, köy and bağlı told apart by a
typecolumn, with every bağlı (mevkii, mezra, …) linked to its köy. - Synced with the official Ministry of Interior inventory: every il, ilçe, mahalle, köy and bağlı is present.
- Configurable table names, foreign key column names and model classes.
- Laravel 12 and 13, PHP 8.3+.
Table of contents
- Requirements
- Installation
- Quick start
- Database schema
- Usage
- Artisan commands
- Configuration
- Customising
- About the data
- Testing
- Changelog
- License
Requirements
| Package version | Laravel | PHP |
|---|---|---|
| 2.x | 12, 13 | 8.3 – 8.5 |
Installation
composer require siberfx/turkiye-address
The service provider (Siberfx\TurkiyePackage\TurkiyeAddressServiceProvider) is registered automatically through package discovery.
Quick start
# Create the cities, districts and neighborhoods tables and import the data
php artisan turkiye:migrate --seed
Or use the standard Laravel workflow — the package migrations are loaded automatically:
php artisan migrate
php artisan db:seed --class="Siberfx\TurkiyePackage\Database\Seeders\TurkiyeSeeder"
To seed together with the rest of your application, call it from database/seeders/DatabaseSeeder.php:
use Siberfx\TurkiyePackage\Database\Seeders\TurkiyeSeeder; public function run(): void { $this->call(TurkiyeSeeder::class); }
The seeder upserts by ID and never truncates or deletes. Re-running it — for example after upgrading the package — adds new records and refreshes existing ones in place, so rows in your own tables that reference cities, districts or neighborhoods are left untouched, and records you deactivated (
is_active = false) stay deactivated. Foreign key checks stay enabled, so it also works on managed databases without superuser rights.
Database schema
cities districts neighborhoods
────────── ────────────── ──────────────────
id PK id PK id PK
name city_id FK → cities city_id FK → cities
slug unique name district_id FK → districts
sort_order slug name
is_active is_active slug
type mahalle | koy | bagli
parent_id FK → neighborhoods (köy of a bağlı)
is_active
cities.idis the official plate code (1 = Adana, 6 = Ankara, 34 = İstanbul, …).cities.sort_orderputs the configured big cities first and the rest in Turkish alphabetical order. See City order.neighborhoods.city_idis denormalised so you can filter neighborhoods by city without a join.neighborhoods.typetells mahalle, köy and bağlı apart;parent_idlinks a bağlı (mevkii, mezra, …) to its köy. See Mahalle, köy and bağlı.- Foreign keys cascade on delete. Tables have no timestamps.
- Names are stored in upper case as published officially (e.g.
İSTANBUL,KADIKÖY); slugs are ASCII (istanbul,kadikoy).
Usage
Querying
use Siberfx\TurkiyePackage\Models\City; use Siberfx\TurkiyePackage\Models\District; use Siberfx\TurkiyePackage\Models\Neighborhood; // All cities, alphabetically $cities = City::orderBy('name')->get(); // A city by plate code or slug $istanbul = City::find(34); $izmir = City::where('slug', 'izmir')->first(); // Districts of a city $districts = District::where('city_id', 6)->orderBy('name')->get(); // Neighborhoods of a district $neighborhoods = Neighborhood::where('district_id', 1103)->get(); // Search neighborhoods by name inside a city $results = Neighborhood::where('city_id', 34) ->where('name', 'like', '%BAĞDAT%') ->limit(20) ->get(); // Key/value list for a <select> $options = City::orderBy('name')->pluck('name', 'id');
Relationships
| Model | Relationship | Type |
|---|---|---|
City |
districts() |
HasMany |
City |
neighborhoods() |
HasMany |
District |
city() |
BelongsTo |
District |
neighborhoods() |
HasMany |
City |
villages() |
HasMany |
District |
villages() |
HasMany |
Neighborhood |
district() |
BelongsTo |
Neighborhood |
city() |
BelongsTo |
Neighborhood |
parent() |
BelongsTo |
Neighborhood |
children() |
HasMany |
$city = City::find(35); $city->districts; // all districts of İzmir $city->neighborhoods()->count(); // number of neighborhoods in İzmir $district = District::with('city')->find(1103); echo "{$district->name} / {$district->city->name}"; // ADALAR / İSTANBUL // Full address line for a neighborhood $neighborhood = Neighborhood::with(['district', 'city'])->find(1); echo implode(', ', [ $neighborhood->name, $neighborhood->district->name, $neighborhood->city->name, ]); // AHMET REMZİ YÜREĞİR MAHALLESİ, SEYHAN, ADANA // Eager-load counts $cities = City::withCount(['districts', 'neighborhoods'])->get(); // Cities with more than 20 districts $big = City::has('districts', '>', 20)->get();
Mahalle, köy and bağlı
The neighborhoods table holds the three kinds of settlement below a district, told apart by type (cast to the NeighborhoodType enum):
type |
Enum | What it is | Example name |
|---|---|---|---|
mahalle |
NeighborhoodType::Mahalle |
Neighborhood, including mahalles inside a belde (town) | KADIKÖY MAHALLESİ, KÖMÜR BELDESİ, GAZİ MAHALLESİ |
koy |
NeighborhoodType::Koy |
Village | AKDURAK KÖYÜ |
bagli |
NeighborhoodType::Bagli |
Bağlı: a mevkii, mezra, yayla evleri, … that belongs to a köy (parent_id) |
AKDURAK KÖYÜ, ÇAKMAK MEZRASI |
Mahalle and köy are the same administrative level (both sit directly under the district and have a muhtar), so they share the table; a bağlı points at its köy through parent_id.
use Siberfx\TurkiyePackage\Enums\NeighborhoodType; use Siberfx\TurkiyePackage\Models\District; use Siberfx\TurkiyePackage\Models\Neighborhood; $district = District::find(1182); // BESNİ // Only mahalles / only köys / only bağlı $mahalleler = $district->neighborhoods()->mahalle()->orderBy('name')->get(); $koyler = $district->villages()->orderBy('name')->get(); // same as ->neighborhoods()->koy() $bagli = $district->neighborhoods()->bagli()->get(); $sameThing = Neighborhood::ofType(NeighborhoodType::Koy)->where('district_id', 1182)->get(); // The bağlı of a köy, and the köy of a bağlı $koy = Neighborhood::find(4308); // AKDURAK KÖYÜ $koy->children->pluck('name'); // AKDURAK KÖYÜ, ÇAKMAK MEZRASI, … $koy->children()->first()->parent->is($koy); // true // Branch on the type if ($neighborhood->type === NeighborhoodType::Bagli) { $label = $neighborhood->parent?->name; } // Counts per type for a city Neighborhood::where('city_id', 2) ->selectRaw('type, count(*) as total') ->groupBy('type') ->pluck('total', 'type'); // ['bagli' => …, 'koy' => …, 'mahalle' => …]
A typical address form offers mahalle and köy, and the bağlı only once a köy is chosen:
Route::get('/api/districts/{district}/settlements', fn (District $district) => $district->neighborhoods()->whereIn('type', ['mahalle', 'koy'])->active()->orderBy('name')->get(['id', 'name', 'type']) ); Route::get('/api/villages/{koy}/bagli', fn (Neighborhood $koy) => $koy->children()->active()->orderBy('name')->get(['id', 'name']) );
City order (big cities first)
Most users live in a handful of provinces, so city pickers usually list the big cities first instead of starting at Adana. City::ordered() does that:
City::ordered()->pluck('name'); // İSTANBUL, ANKARA, İZMİR, ADANA, ADIYAMAN, AFYONKARAHİSAR, AĞRI, … ZONGULDAK City::active()->ordered()->get(['id', 'name']); // for a <select>
- The cities listed first come from
featured_citiesin the config (plate codes). The default is the three most populous provinces: İstanbul (34), Ankara (6), İzmir (35). - All other cities follow in Turkish alphabetical order (
ÇANAKKALEafterBURSA,IĞDIRbeforeISPARTAbeforeİZMİR,ŞANLIURFAafterSİVAS), regardless of the database collation. - The order is stored in
cities.sort_order(1, 2, 3, …), so you can alsoorderBy('sort_order')in your own queries and joins.
Change the featured cities in .env or the config, then recalculate:
TURKIYE_FEATURED_CITIES=34,6,35,16,7 # İstanbul, Ankara, İzmir, Bursa, Antalya
php artisan turkiye:sort-cities # from the config php artisan turkiye:sort-cities 34 35 16 # or pass plate codes directly
The seeder and the migration that adds the column apply the configured order automatically.
Active scope
Every row has an is_active flag (cast to bool). Deactivate records you don't want to offer and filter with the active() scope:
District::where('city_id', 34)->update(['is_active' => false]); District::whereIn('id', [1103, 1166])->update(['is_active' => true]); $offered = District::active()->where('city_id', 34)->get(); $city->districts()->active()->get();
Validation
use Illuminate\Validation\Rule; $request->validate([ 'city_id' => ['required', 'integer', 'exists:cities,id'], 'district_id' => [ 'required', 'integer', Rule::exists('districts', 'id')->where('city_id', $request->integer('city_id')), ], 'neighborhood_id' => [ 'required', 'integer', Rule::exists('neighborhoods', 'id')->where('district_id', $request->integer('district_id')), ], ]);
The scoped Rule::exists checks make sure the district belongs to the selected city and the neighborhood to the selected district.
Dependent dropdowns (city → district → neighborhood)
Routes / controller:
use Illuminate\Support\Facades\Route; use Siberfx\TurkiyePackage\Models\City; use Siberfx\TurkiyePackage\Models\District; Route::get('/api/cities', fn () => City::active()->orderBy('name')->get(['id', 'name']) ); Route::get('/api/cities/{city}/districts', fn (City $city) => $city->districts()->active()->orderBy('name')->get(['id', 'name']) ); Route::get('/api/districts/{district}/neighborhoods', fn (District $district) => $district->neighborhoods()->active()->orderBy('name')->get(['id', 'name']) );
Front end (vanilla JS):
<select id="city"></select> <select id="district" disabled></select> <select id="neighborhood" disabled></select> <script> const fill = async (select, url) => { const items = await (await fetch(url)).json(); select.innerHTML = '<option value="">Select…</option>' + items.map(i => `<option value="${i.id}">${i.name}</option>`).join(''); select.disabled = false; }; const city = document.getElementById('city'); const district = document.getElementById('district'); const neighborhood = document.getElementById('neighborhood'); fill(city, '/api/cities'); city.addEventListener('change', () => { neighborhood.disabled = true; fill(district, `/api/cities/${city.value}/districts`); }); district.addEventListener('change', () => fill(neighborhood, `/api/districts/${district.value}/neighborhoods`) ); </script>
Livewire 3 component:
use Livewire\Component; use Siberfx\TurkiyePackage\Models\City; use Siberfx\TurkiyePackage\Models\District; use Siberfx\TurkiyePackage\Models\Neighborhood; class AddressPicker extends Component { public ?int $cityId = null; public ?int $districtId = null; public ?int $neighborhoodId = null; public function updatedCityId(): void { $this->reset('districtId', 'neighborhoodId'); } public function updatedDistrictId(): void { $this->reset('neighborhoodId'); } public function render() { return view('livewire.address-picker', [ 'cities' => City::orderBy('name')->pluck('name', 'id'), 'districts' => $this->cityId ? District::where('city_id', $this->cityId)->orderBy('name')->pluck('name', 'id') : collect(), 'neighborhoods' => $this->districtId ? Neighborhood::where('district_id', $this->districtId)->orderBy('name')->pluck('name', 'id') : collect(), ]); } }
Attaching addresses to your own models
// Migration Schema::create('addresses', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->foreignId('city_id')->constrained('cities'); $table->foreignId('district_id')->constrained('districts'); $table->foreignId('neighborhood_id')->nullable()->constrained('neighborhoods'); $table->string('line'); $table->string('postal_code', 5)->nullable(); $table->timestamps(); });
// app/Models/Address.php use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Siberfx\TurkiyePackage\Models\City; use Siberfx\TurkiyePackage\Models\District; use Siberfx\TurkiyePackage\Models\Neighborhood; class Address extends Model { protected $fillable = ['city_id', 'district_id', 'neighborhood_id', 'line', 'postal_code']; public function city(): BelongsTo { return $this->belongsTo(City::class); } public function district(): BelongsTo { return $this->belongsTo(District::class); } public function neighborhood(): BelongsTo { return $this->belongsTo(Neighborhood::class); } public function fullAddress(): string { return collect([ $this->line, $this->neighborhood?->name, $this->district->name . ' / ' . $this->city->name, $this->postal_code, ])->filter()->implode(', '); } }
Caching
The address data rarely changes, so it is a good candidate for caching:
use Illuminate\Support\Facades\Cache; $cities = Cache::rememberForever('turkiye.cities', fn () => City::orderBy('name')->get(['id', 'name', 'slug']) ); $districts = Cache::rememberForever("turkiye.cities.{$cityId}.districts", fn () => District::where('city_id', $cityId)->orderBy('name')->get(['id', 'name']) );
Clear these keys after re-seeding or toggling is_active.
Artisan commands
turkiye:migrate
Runs only the package migrations.
php artisan turkiye:migrate # run the package migrations php artisan turkiye:migrate --seed # ...and import the data php artisan turkiye:migrate --force # allow running in production php artisan turkiye:migrate --fresh # ⚠ drops ALL tables of the database first (migrate:fresh)
turkiye:sort-cities
Recalculates cities.sort_order: featured cities first, then Turkish alphabetical order. See City order.
php artisan turkiye:sort-cities # featured cities from config('turkiye-package.featured_cities') php artisan turkiye:sort-cities 34 6 35 16 # İstanbul, Ankara, İzmir, Bursa first
turkiye:publish
Publishes package files into your application. With no option, everything is published.
php artisan turkiye:publish # config + migrations + seeder & SQL dumps php artisan turkiye:publish --config # config/turkiye-package.php php artisan turkiye:publish --migrations # database/migrations/* php artisan turkiye:publish --seeders # database/seeders/TurkiyeSeeder.php + database/sql-dumps/*.sql php artisan turkiye:publish --force # overwrite existing files
The same files can be published with vendor:publish:
php artisan vendor:publish --provider="Siberfx\TurkiyePackage\TurkiyeAddressServiceProvider" --tag=config
Available tags: config, migrations, seeders.
Configuration
Publish the config file if you need to change the defaults:
php artisan turkiye:publish --config
// config/turkiye-package.php return [ // Table names 'cities_table' => env('TURKIYE_CITIES_TABLE', 'cities'), 'districts_table' => env('TURKIYE_DISTRICTS_TABLE', 'districts'), 'neighborhoods_table' => env('TURKIYE_NEIGHBORHOODS_TABLE', 'neighborhoods'), // Foreign key column names 'cities_relation_id' => env('TURKIYE_CITIES_RELATION_ID', 'city_id'), 'districts_relation_id' => env('TURKIYE_DISTRICTS_RELATION_ID', 'district_id'), // Cities listed first by City::ordered(); default İstanbul, Ankara, İzmir 'featured_cities' => array_map(intval(...), array_filter(explode(',', (string) env('TURKIYE_FEATURED_CITIES', '34,6,35')))), // Model classes used by the relationships 'city_model' => Siberfx\TurkiyePackage\Models\City::class, 'district_model' => Siberfx\TurkiyePackage\Models\District::class, 'neighborhood_model' => Siberfx\TurkiyePackage\Models\Neighborhood::class, ];
Example — avoid clashing with an existing cities table by prefixing all tables via .env:
TURKIYE_CITIES_TABLE=tr_cities TURKIYE_DISTRICTS_TABLE=tr_districts TURKIYE_NEIGHBORHOODS_TABLE=tr_neighborhoods
Migrations, seeder and models all read these values. Set them before running the migrations.
Customising
Your own models
Extend the package models to add attributes, scopes or relationships, then point the config at them so the package relationships return your classes:
namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; use Siberfx\TurkiyePackage\Models\City as BaseCity; class City extends BaseCity { // "İSTANBUL" -> "İstanbul" protected function displayName(): Attribute { return Attribute::get(fn () => mb_convert_case( mb_strtolower($this->name, 'UTF-8'), MB_CASE_TITLE, 'UTF-8' )); } }
// config/turkiye-package.php 'city_model' => App\Models\City::class,
Tip:
mb_strtolowerdoes not apply Turkish casing rules forI/İ. If exact Turkish casing matters, useStr::lower()after replacingI→ıandİ→i.
Your own seeder or data
php artisan turkiye:publish --seeders creates:
database/seeders/TurkiyeSeeder.php— a class extending the package seeder, where you can override any step (importCities(),importDistricts(),importNeighborhoods(),dumpPath(), …).database/sql-dumps/*.sql— editable copies of the data. When these files exist the seeder uses them instead of the bundled dumps.
namespace Database\Seeders; use Illuminate\Support\Facades\DB; use Siberfx\TurkiyePackage\Database\Seeders\TurkiyeSeeder as BaseTurkiyeSeeder; class TurkiyeSeeder extends BaseTurkiyeSeeder { public function run(): void { parent::run(); // Only offer neighborhoods proper (skip köy / mevkii / mezra entries) DB::table($this->neighborhoodsTable) ->where('name', 'not like', '%MAHALLESİ') ->update(['is_active' => false]); } }
php artisan db:seed --class="Database\Seeders\TurkiyeSeeder"
About the data
| Level | Rows | Notes |
|---|---|---|
| Cities | 81 | IDs are the official plate codes |
| Districts | 973 | Every province's district count matches the official administrative list |
| Neighborhoods | 94,933 | 33,097 mahalle · 18,377 köy · 43,459 bağlı (mevkii, mezra, yayla evleri, …) |
The neighborhood level started from the postal (PTT) address register and has been completed from the official Ministry of Interior lists (see below). Every köy has its own row, and 42,997 bağlı are linked to their köy through parent_id; the remaining 462 are legacy postal entries whose köy no longer exists officially (most became mahalles in the 2012 metropolitan reform), so their parent_id is null. Street (sokak) level data is not included.
Source and official reference
İl, ilçe, mahalle, köy and bağlı records are checked against the official inventory of administrative divisions published by the Ministry of Interior (İçişleri Bakanlığı, İller İdaresi Genel Müdürlüğü):
Türkiye Mülki İdare Bölümleri Envanteri — e-icisleri.gov.tr
| Official list (October 2026) | Official count | Present in this package |
|---|---|---|
| İl (province) | 81 | all |
| İlçe (district) | 922 + 51 MERKEZ |
all |
| Mahalle (neighborhood) | 32,327 | all |
| Köy (village) | 18,210 | all |
| Bağlı (attached to a köy) | 23,570 | all 23,567 places¹ |
¹ The official bağlı list also contains 3 water administrations attached to a metropolitan municipality; they are not places and are not imported.
- Records that were missing from the original dataset were added with IDs starting at 1,000,000, so existing IDs — and any foreign keys pointing at them in your application — never change.
- Names use the official spelling. About 2,000 records that differed by a single letter (e.g.
KEKLİKÇİ→KEKLİKÇE) were renamed in place, keeping their IDs; when a köy is renamed, its bağlı are renamed with it. Note that renamed records also get a newslug. - Records are never deleted or renumbered by a data update. When a place was renamed or moved to another district, the current record has its own row and the old row is kept with
is_active = false(28 rows, listed with the reason insrc/Database/sql-dumps/inactive.json). Filter withactive()to hide them.
Found a missing or outdated record? Please open an issue with the official source.
Testing
composer install
composer test
Locally the suite runs against an in-memory SQLite database. CI runs it on PHP 8.3, 8.4 and 8.5 with Laravel 12 and 13, and additionally against MySQL 8.4 and PostgreSQL 17. To run it against your own database, set the DB_* variables:
DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_PORT=5432 DB_DATABASE=testing DB_USERNAME=postgres DB_PASSWORD=secret vendor/bin/phpunit
| Test file | Covers |
|---|---|
tests/Feature/SeederTest.php |
Full import: idempotent re-runs, exact row counts, counts per type, referential integrity, bağlı → köy links, records synced from the official lists, escaped names, published-dump override |
tests/Feature/NeighborhoodModelTest.php |
type enum cast and default, mahalle() / koy() / bagli() / ofType() scopes, parent() / children(), villages(), ON DELETE SET NULL for bağlı |
tests/Feature/ReseedTest.php |
Re-seeding keeps application rows that reference the address tables and is_active flags; parents written before children with foreign keys on; ID sequences usable afterwards |
tests/Feature/CustomConfigurationTest.php |
Custom table and foreign key column names across migrations, seeder and models |
tests/Feature/ServiceProviderTest.php |
Provider registration, turkiye:migrate, turkiye:publish, model relationships, active() scope |
tests/Feature/CityOrderingTest.php |
City::ordered(), default featured cities, turkiye:sort-cities, TURKIYE_FEATURED_CITIES, ordering an existing installation on migrate |
tests/Unit/CityOrderTest.php |
Turkish alphabetical comparison, featured-first ordering |
tests/Unit/NeighborhoodTypeTest.php |
Deriving mahalle / köy / bağlı from a name, stable enum values |
Run a single file or test:
vendor/bin/phpunit tests/Feature/NeighborhoodModelTest.php vendor/bin/phpunit --filter test_seeder_sets_types_and_links_bagli_to_their_koy
Changelog
See CHANGELOG.md for what has changed between versions.
Contributing
Contributions are welcome! Please open an issue first to discuss the change you would like to make.
License
This package is open-sourced software licensed under the MIT license.
Contact
SiberFX — info@siberfx.com