# Tanzania Locations — Usage Guide

Offline, framework-agnostic Tanzania location data: **Region → District → Ward → Postcode**,
with cascading dropdowns and a safe update workflow. No database required.

- 30 regions (including the 4 **Verification Regions**: Ilala, Temeke, Kinondoni, WMA Ports Unit)
- 147 districts, ~4,060 wards, postcodes attached per ward
- Works the same in plain PHP, **Laravel**, **CodeIgniter**, or anything else with Composer

---

## 1. Installation

The package lives in its own folder/repo: `tanzania-locations/`.

**Option A — local path (fastest, no git hosting needed).** In your project's `composer.json`:

```json
{
    "repositories": [
        { "type": "path", "url": "/path/to/tanzania-locations" }
    ]
}
```

```bash
composer require cassim/tanzania-locations:@dev
```

**Option B — your own GitHub/GitLab repo.** Push the `tanzania-locations` folder as a repo,
tag it (`git tag v1.0.0`), then:

```json
{
    "repositories": [
        { "type": "vcs", "url": "https://github.com/CassimClick/tanzania-locations" } 
    ]
}
```

```bash
composer require cassim/tanzania-locations:^1.0
```

**Option C — Packagist.** Submit the repo on packagist.org once, then it's a plain
`composer require` everywhere, no `repositories` block.

> Rename the package by editing `name` in the package's `composer.json`
> (e.g. `yourcompany/tanzania-locations`) before publishing.

---

## 2. Quick start (any PHP project)

```php
require 'vendor/autoload.php';

use TanzaniaLocations\TanzaniaLocations;

$tz = new TanzaniaLocations();

$tz->regions();                          // all 30 regions, sorted by name
$tz->regions(false);                     // 26 regions, Verification Regions hidden
$tz->districts('arusha');                // districts of a region (id or slug works)
$tz->wards(2);                           // wards of a district — each ward has postcodes
$tz->postcodes(17);                      // ["23112"]

$tz->ward(17);                           // ward + its district/region names ("context")
$tz->search('Kurasini');                 // search by name or postcode
$tz->isValidHierarchy(1, 2, 17);         // does ward 17 belong to district 2 in region 1?
$tz->version();                          // "1.0.0"
```

Every method returns plain arrays — `foreach` them, `json_encode` them, pass them to any
template engine.

**IDs vs slugs.** Every record has a stable integer `id` (store this in your database) and
a `slug` (use in URLs). District/ward slugs can repeat across parents — e.g. `temeke` is a
district in three regions — so either scope them (`$tz->district('temeke', 'dar-es-salaam')`,
or the path form `$tz->district('dar-es-salaam/temeke')`) or use ids. Ambiguous slugs throw
an exception rather than guessing.

**Verification Regions.** Ilala, Temeke, Kinondoni and WMA Ports Unit are operational
pseudo-regions (`"verification": true` in the data). They are **included by default**
everywhere; pass `false` (PHP) / `includeVerification: false` (JS) / `&verification=0`
(AJAX) to hide them for public-facing forms.

---

## 3. Cascading dropdowns — the fastest way

**Mode A (recommended): fully client-side.** The whole dataset is inlined once
(~200 KB, ~30 KB gzipped); the cascade is instant, no endpoints to build.

```html
<select id="region"></select>
<select id="district"></select>
<select id="ward"></select>
<input id="postcode" readonly>

<script src="/js/tz-cascade.js"></script>
<script>
TzCascade.init({
    data: <?= $tz->treeJson() ?>,
    region: '#region', district: '#district', ward: '#ward',
    postcode: '#postcode'          // auto-filled when a ward is picked
});
</script>
```

**Mode B: AJAX.** One endpoint, nothing shipped up-front:

```php
// api/locations.php
require 'vendor/autoload.php';
(new TanzaniaLocations\AjaxHandler())->handle();
```

```js
TzCascade.init({ url: '/api/locations.php', region: '#region', district: '#district', ward: '#ward' });
```

**All `TzCascade.init` options:**

| Option | Meaning |
|---|---|
| `data` | tree object, JSON string, or URL to `dist/locations.min.json` (Mode A) |
| `url` | AJAX endpoint (Mode B) — used when `data` is absent |
| `region` / `district` / `ward` | selector or element for each `<select>` |
| `postcode` | optional `<input>` (codes joined by comma) or `<select>` |
| `selected` | `{ region: 1, district: 2, ward: 17 }` — preselect chain for edit forms |
| `includeVerification` | `false` hides Verification Regions (default `true`) |
| `placeholders` | override the `-- Select … --` texts |
| `onChange(level, item, selection)` | called on every change |

Each select also fires a bubbling `tz:change` CustomEvent. `init()` returns
`{ ready, getSelection, setSelection, reset }`.

**AJAX endpoint parameters** (`AjaxHandler`): `?regions=1` · `?districts=<region>` ·
`?wards=<district>` · `?postcodes=<ward>` · `?search=<term>` · `?tree=1` · add
`&verification=0` to hide Verification Regions. Response: `{"ok":true,"data":[...]}`.

---

## 4. Laravel

### 4.1 Setup

```bash
composer require cassim/tanzania-locations:@dev
cp vendor/cassim/tanzania-locations/assets/tz-cascade.js public/js/
```

No service provider or config needed — it's a plain class, and Laravel's container can
inject it anywhere.

### 4.2 Form with cascading dropdowns (Blade, Mode A)

```php
// routes/web.php
use App\Http\Controllers\AddressController;

Route::get('/addresses/create', [AddressController::class, 'create']);
Route::post('/addresses', [AddressController::class, 'store']);
```

```php
// app/Http/Controllers/AddressController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use TanzaniaLocations\TanzaniaLocations;

class AddressController extends Controller
{
    public function create(TanzaniaLocations $tz)
    {
        return view('addresses.create', ['tz' => $tz]);
    }

    public function store(Request $request, TanzaniaLocations $tz)
    {
        $data = $request->validate([
            'region_id'   => 'required|integer',
            'district_id' => 'required|integer',
            'ward_id'     => 'required|integer',
        ]);

        // never trust the cascade client-side — verify the chain on the server
        if (! $tz->isValidHierarchy($data['region_id'], $data['district_id'], $data['ward_id'])) {
            return back()->withErrors(['ward_id' => 'The selected location is not valid.'])->withInput();
        }

        $request->user()->addresses()->create($data);
        return redirect('/addresses');
    }
}
```

```blade
{{-- resources/views/addresses/create.blade.php --}}
<form method="POST" action="/addresses">
    @csrf
    <label>Region</label>
    <select id="region" name="region_id" required></select>

    <label>District</label>
    <select id="district" name="district_id" required></select>

    <label>Ward</label>
    <select id="ward" name="ward_id" required></select>

    <label>Postcode</label>
    <input id="postcode" name="postcode" readonly>

    <button type="submit">Save</button>
</form>

<script src="{{ asset('js/tz-cascade.js') }}"></script>
<script>
TzCascade.init({
    data: {!! $tz->treeJson() !!},   {{-- treeJson() is already JSON: use {!! !!}, not @json --}}
    region: '#region', district: '#district', ward: '#ward', postcode: '#postcode',
    selected: {
        region:   @json(old('region_id')),
        district: @json(old('district_id')),
        ward:     @json(old('ward_id'))
    }
});
</script>
```

For an **edit form**, pass the stored ids the same way:
`selected: { region: {{ $address->region_id }}, district: {{ $address->district_id }}, ward: {{ $address->ward_id }} }`.

### 4.3 AJAX mode instead (Mode B)

```php
// routes/web.php  (or api.php)
use TanzaniaLocations\AjaxHandler;

Route::get('/api/locations', fn () => response()->json(
    (new AjaxHandler())->response(request()->query())
));
```

```js
TzCascade.init({ url: '/api/locations', region: '#region', district: '#district', ward: '#ward' });
```

### 4.4 Displaying a stored address

Store only the three integer ids (`region_id`, `district_id`, `ward_id`); resolve names
when displaying:

```php
$ward = $tz->ward($address->ward_id);
// "Kurasini, Temeke, Dar es Salaam — 15109"
echo "{$ward['name']}, {$ward['district']}, {$ward['region']}"
   . ($ward['postcodes'] ? ' — ' . implode(', ', $ward['postcodes']) : '');
```

### 4.5 Migrating your database columns (optional but recommended)

If your tables currently store region/district/ward **names**, switch to ids:

```php
Schema::table('addresses', function (Blueprint $table) {
    $table->unsignedInteger('region_id')->nullable();
    $table->unsignedInteger('district_id')->nullable();
    $table->unsignedInteger('ward_id')->nullable();
});
```

Backfill by matching names through `$tz->search()` or the slug lookups, then drop the
old string columns. Ids are stable across dataset updates; names keep working even if
spelling is corrected later.

---

## 5. CodeIgniter

### 5.1 CodeIgniter 4

```bash
composer require cassim/tanzania-locations:@dev
cp vendor/cassim/tanzania-locations/assets/tz-cascade.js public/js/
```

```php
// app/Config/Routes.php
$routes->get('addresses/new', 'Addresses::new');
$routes->post('addresses', 'Addresses::create');
$routes->get('api/locations', 'Addresses::locations');   // only needed for Mode B
```

```php
// app/Controllers/Addresses.php
namespace App\Controllers;

use TanzaniaLocations\AjaxHandler;
use TanzaniaLocations\TanzaniaLocations;

class Addresses extends BaseController
{
    public function new()
    {
        return view('addresses/form', ['tz' => new TanzaniaLocations()]);
    }

    public function create()
    {
        $tz = new TanzaniaLocations();
        $regionId   = (int) $this->request->getPost('region_id');
        $districtId = (int) $this->request->getPost('district_id');
        $wardId     = (int) $this->request->getPost('ward_id');

        if (! $tz->isValidHierarchy($regionId, $districtId, $wardId)) {
            return redirect()->back()->withInput()->with('error', 'The selected location is not valid.');
        }

        model(\App\Models\AddressModel::class)->save([
            'region_id' => $regionId, 'district_id' => $districtId, 'ward_id' => $wardId,
        ]);
        return redirect()->to('/addresses');
    }

    public function locations()   // Mode B endpoint
    {
        return $this->response->setJSON(
            (new AjaxHandler())->response($this->request->getGet())
        );
    }
}
```

```php
<!-- app/Views/addresses/form.php -->
<form method="post" action="<?= site_url('addresses') ?>">
    <?= csrf_field() ?>
    <label>Region</label>   <select id="region" name="region_id" required></select>
    <label>District</label> <select id="district" name="district_id" required></select>
    <label>Ward</label>     <select id="ward" name="ward_id" required></select>
    <label>Postcode</label> <input id="postcode" name="postcode" readonly>
    <button type="submit">Save</button>
</form>

<script src="<?= base_url('js/tz-cascade.js') ?>"></script>
<script>
TzCascade.init({
    data: <?= $tz->treeJson() ?>,
    region: '#region', district: '#district', ward: '#ward', postcode: '#postcode',
    selected: {
        region:   <?= json_encode(old('region_id')) ?>,
        district: <?= json_encode(old('district_id')) ?>,
        ward:     <?= json_encode(old('ward_id')) ?>
    }
});
</script>
```

To use **Mode B** instead, replace the `data:` line with `url: '<?= site_url('api/locations') ?>',`.

### 5.2 CodeIgniter 3

Enable Composer autoloading in `application/config/config.php`:

```php
$config['composer_autoload'] = FCPATH . 'vendor/autoload.php';
```

Then the same classes work in any controller:

```php
$tz = new \TanzaniaLocations\TanzaniaLocations();
$data['tz'] = $tz;
$this->load->view('address_form', $data);
```

---

## 6. Adding & updating data — the correct way

The single source of truth is **`data/locations.json`** inside the package. Never edit
anything in `dist/` — those files are generated.

### 6.1 Adding records (use the CLI — it does it correctly for you)

Run inside the package folder:

```bash
# a new region
php bin/add.php region --name="New Region"

# a new Verification Region
php bin/add.php region --name="Airport Unit" --verification

# a new district (region by id or slug)
php bin/add.php district --region=njombe --name="Ludewa Mjini"

# a new ward with its postcode(s)
php bin/add.php ward --region=arusha --district=arumeru --name="Ngurdoto" --postcodes=23306
```

The CLI assigns the **next free id**, generates the slug, inserts alphabetically,
**rejects duplicates**, checks the postcode format, and rebuilds `dist/` automatically.
Nothing else to do.

### 6.2 Editing or removing records (manual JSON edit)

Open `data/locations.json`, make the change, then run:

```bash
php bin/build.php
```

The build **fails loudly** on duplicate ids, duplicate slugs under one parent, empty
names, or malformed postcodes — broken data can never reach your projects.

Rules that keep every project safe:

- **Never change or reuse an `id`.** Your apps store these ids; renaming a ward is fine
  (the id keeps references valid), renumbering is not.
- **Fix spelling via `name`** (and `slug` if you want the URL form to follow).
- **Before deleting** a record, be sure no app database references its id.
- Postcodes are an array on the ward: `"postcodes": ["23306"]` — a ward may have several
  or none.

### 6.3 Releasing an update to all your projects

```bash
# inside the package repo, after add.php / build.php succeeded:
# 1. bump "version" in data/locations.json  (calendar style: 2026.08.1)
php bin/build.php
git add -A && git commit -m "Add Ngurdoto ward (dataset 2026.08.1)"
git tag v2026.08.1 && git push --tags
```

Then, in **every consuming project**:

```bash
composer update cassim/tanzania-locations
```

…and the new data is live. Nothing to re-import, no SQL patches. (Path-repo installs
pick the change up immediately on `composer update`; check `$tz->version()` at runtime
if you need to confirm.)

### 6.4 If you loaded the data into a database (Mode C)

Some projects prefer real tables to JOIN against:

```bash
mysql my_database < vendor/cassim/tanzania-locations/dist/schema.sql
```

This creates FK-linked `tz_regions`, `tz_districts`, `tz_wards`, `tz_postcodes`
(re-running the file drops and recreates them — safe to repeat after every update).
SQLite/Postgres flavors:

```php
file_put_contents('schema.pgsql.sql', (new TanzaniaLocations\SqlExporter())->export('pgsql'));
```

---

## 7. PHP API reference

| Method | Returns |
|---|---|
| `regions(bool $includeVerification = true)` | all regions `{id, name, slug, verification}` sorted by name |
| `region(id\|slug)` | one region or `null` |
| `districts(region)` | districts of a region `{id, name, slug, region_id}` |
| `district(id\|slug, ?region)` | one district + `region` name; throws on ambiguous slug |
| `wards(district, ?region)` | wards `{id, name, slug, district_id, postcodes[]}` |
| `ward(id\|slug, ?district)` | one ward + `district`, `region_id`, `region` context |
| `postcodes(ward)` | `string[]` of codes |
| `isValidHierarchy(region, district, ward)` | `bool` — server-side form validation |
| `search(term, limit = 20)` | matches across all levels, by name or postcode, with a `path` label |
| `tree(bool $includeVerification = true)` | full nested array for client-side use |
| `treeJson(...)` | same, as a JSON string ready to inline |
| `version()` | dataset version string |

---

## 8. Where this data came from

The dataset was migrated from the legacy `region-district-ward-postcode.sql` dump by
`bin/migrate-legacy.php`. The migration fixed name-based joins, whitespace/casing bugs,
duplicate rows, recovered the missing Njombe and Kibiti districts and several district
typos (`Kiliua` → Kaliua, `llala` → Ilala, `ChunyaK` → Chunya, …), and scoped ambiguous
postcodes by their region prefix. Every change — and the handful of rows that could not
be resolved automatically — is listed in `migration-report.txt`. Add anything missing
with `bin/add.php`.
