aldesrahim / laravel-timezone-indonesia
Resolve an Indonesian IANA timezone from a coordinate, offline, from bundled timezone-boundary-builder geometry.
Package info
github.com/aldesrahim/laravel-timezone-indonesia
pkg:composer/aldesrahim/laravel-timezone-indonesia
Requires
- php: ^8.2
- illuminate/console: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^3.0 || ^4.0
README
Turn a coordinate into an Indonesian IANA timezone, offline.
use Aldesrahim\TimezoneIndonesia\Facades\TimezoneIndonesia; TimezoneIndonesia::at(-6.2088, 106.8456); // 'Asia/Jakarta' TimezoneIndonesia::at(1.3521, 103.8198); // null — Singapore, outside Indonesia
The boundaries ship with the package: four polygons filtered from timezone-boundary-builder by aldesrahim/timezone-indonesia. No API, no database, no network call at runtime. Their union is Indonesia, so the same lookup answers both "which timezone" and "is this in Indonesia at all".
Install
composer require aldesrahim/laravel-timezone-indonesia
That is the whole setup. The config file is optional:
php artisan vendor:publish --tag=timezone-indonesia-config
Requires PHP 8.2+ and Laravel 12 or 13.
Usage
| Call | Returns |
|---|---|
at(float $lat, float $lon) |
'Asia/Jakarta', or null outside Indonesia |
zoneAt(float $lat, float $lon) |
Zone enum, or null |
timezoneAt(float $lat, float $lon) |
DateTimeZone, or null |
contains(float $lat, float $lon) |
bool |
dataVersion() |
'2026c' — the upstream release loaded |
metadata() |
provenance: release, timestamp, bounding boxes, checksum |
zones() |
the four Zone cases |
Arguments are (latitude, longitude) everywhere, the order people write coordinates in. GeoJSON
stores [longitude, latitude]; that inversion is handled inside the package. A coordinate that
cannot exist — including latitude and longitude passed the wrong way round — throws
InvalidCoordinate rather than quietly returning null, which would be indistinguishable from
"outside Indonesia".
The Zone enum carries what applications usually want next:
use Aldesrahim\TimezoneIndonesia\Zone; $zone = TimezoneIndonesia::zoneAt(-5.1477, 119.4327); // Zone::Makassar $zone->value; // 'Asia/Makassar' $zone->abbreviation(); // 'WITA' $zone->offsetHours(); // 8 $zone->region(); // 'South/East/North Kalimantan, Sulawesi, Bali, NTB, NTT' $zone->timezone(); // DateTimeZone Zone::tryFromTzid('Asia/Ujung_Pandang'); // Zone::Makassar — legacy aliases resolve
Inject the class if you would rather not use the facade:
public function __construct(private TimezoneIndonesia $timezones) {}
| tzid | Abbreviation | Offset | Covers |
|---|---|---|---|
Asia/Jakarta |
WIB | UTC+7 | Java, Sumatra |
Asia/Pontianak |
WIB | UTC+7 | West and Central Kalimantan |
Asia/Makassar |
WITA | UTC+8 | South/East/North Kalimantan, Sulawesi, Bali, NTB, NTT |
Asia/Jayapura |
WIT | UTC+9 | Maluku, Papua |
What the polygons cover
Internal seas are inside a zone; open ocean is not. Upstream uses territorial waters rather than Exclusive Economic Zones, and in an archipelagic state that puts the water between the islands inside. A coordinate from a pier or a ferry still lands in a named zone, and GPS drift near the coast does not produce a spurious "outside Indonesia".
| Point | Resolves to |
|---|---|
| Java Sea, mid-water | Asia/Jakarta |
| Makassar Strait | Asia/Makassar |
| Banda Sea | Asia/Jayapura |
| Indian Ocean, southwest of Java | null |
| Oecusse, Timor-Leste | null |
| Kota Kinabalu, Kuching, Dili, Singapore, Vanimo | null |
Oecusse is the interesting one: it is a Timor-Leste exclave surrounded by Indonesian West Timor, and
it exists in the data as an interior ring inside Asia/Makassar. Holes are subtracted, so a point
inside it resolves to nothing.
Performance
Measured on PHP 8.5 with the shipped 817 KB file, four polygons, ~36,700 coordinate pairs.
Read the timings with OPcache in mind. Every millisecond figure below was measured on the CLI with OPcache off, so the compiled numbers include compiling the PHP that OPcache would otherwise have cached. With a warm OPcache the compile half goes away and the absolute times drop by an amount this project has not measured. The memory figures do not move: OPcache shares opcodes, and the array a compiled file returns is built again in every process that requires it.
Retained and peak are different numbers, and the gap is large. Building these arrays costs
several times what holding them costs — 1.5 MB of geometry passes through an 11.5 MB peak. Peak is
what a memory_limit is measured against and what decides how many workers a box fits; retained is
what a long-lived process carries between lookups.
The geometry is read on the first lookup and kept for the life of the process, so an application that installs the package and never calls it pays nothing. Under Octane that means one parse per worker rather than one per request — and there the first-lookup cost stops mattering at all, which is worth knowing before optimising for it.
Each lookup rejects on bounding boxes first — four float comparisons — and ray casts only the
survivors. The boxes overlap heavily (Asia/Jakarta reaches 116.5°E, past where Pontianak and
Makassar begin), so they can reject but never select. Kuala Lumpur sits inside Asia/Jakarta's box
and still resolves to nothing.
Optional compile step
php artisan timezone-indonesia:compile
Writes an index to bootstrap/cache/timezone-indonesia.php holding each zone's bounding box, and
the geometry itself to bootstrap/cache/timezone-indonesia-zones/, one file per zone. A lookup
reads the index and then only the zones whose box contains the point — usually one. Undo it with
--clear.
That split is worth more than it sounds, because the four zones are nowhere near equal:
| Zone | Coordinate pairs | Compiled |
|---|---|---|
Asia/Jakarta |
481 | 20 KB |
Asia/Jayapura |
2,259 | 90 KB |
Asia/Pontianak |
14,348 | 750 KB |
Asia/Makassar |
19,610 | 1.1 MB |
Java and Sumatra are 1% of the geometry and hold most of the country's people, so what a lookup costs now depends on where the point is. Reading one compiled file, on its own:
| File | Size | Time | Retained | Peak |
|---|---|---|---|---|
| index | 1 KB | 0.2 ms | — | — |
asia-jakarta.php |
25 KB | 1.4 ms | 0.02 MB | 0.11 MB |
asia-jayapura.php |
120 KB | 5.0 ms | 0.13 MB | 0.85 MB |
asia-pontianak.php |
774 KB | 11.9 ms | 0.51 MB | 4.07 MB |
asia-makassar.php |
1058 KB | 26.2 ms | 0.76 MB | 6.16 MB |
Before the split every lookup read all four. A first lookup in a fresh process, one lookup per process, single-file compile against per-zone compile:
| Point | Reads | Before | After |
|---|---|---|---|
| Jakarta | index + 1 zone | 39–56 ms, 1.47 MB held, 11.5 MB peak | 1.3 ms, 0.08 MB held, 0.12 MB peak |
| Indian Ocean | index only | 39 ms, 1.46 MB held, 11.5 MB peak | 0.5 ms, 0.05 MB held, 0.06 MB peak |
| Denpasar | index + 2 zones | 39 ms, 1.47 MB held, 11.5 MB peak | 40 ms, 0.84 MB held, 6.2 MB peak |
Denpasar is the honest worst case and worth stating plainly: Bali sits in two boxes, one of them
Asia/Makassar, which is the largest file there is. Reading it dominates, so the split saves
nothing on time there — only the other zones' memory. The same is true anywhere in Sulawesi, Nusa
Tenggara or eastern Kalimantan. What the split reliably buys is Java, Sumatra, Papua, and every
coordinate that turns out not to be in Indonesia at all.
A point outside every box answers from the index alone. A point in one box reads that zone. Only the overlaps — Bali, Lombok, the Kalimantan seam — read two. Both halves of the saving survive a warm OPcache in kind if not in size: fewer bytes to compile, and fewer array elements to build.
Files compiled by an earlier version hold every zone inline and carry no format key; they are
still read as they were, they simply cannot be loaded a zone at a time. Recompile to get the split.
Updating the boundaries
The normal path is composer update. A scheduled workflow in this repository checks upstream
monthly, verifies the checksum, runs the suite against the new data, and opens a pull request;
merging it publishes a minor release.
If you need boundaries before the package ships them:
php artisan timezone-indonesia:update # download the latest release php artisan timezone-indonesia:update --check # exit 1 when newer boundaries exist php artisan timezone-indonesia:update --tag=2026c
The download is verified against the SHA-256 in its own metadata and parsed before anything is
written, so a truncated or foreign file cannot replace a working one. Files land in
storage/app/timezone-indonesia (configurable) and take precedence over the bundled copy; delete
them to go back. If a compiled file exists it is rebuilt automatically. This command is the only
network code in the package, it never runs on its own, and everything works with the network
disabled.
Testing
composer test
The point fixtures are ported verbatim from the data repository's validation script: 15 coordinates
that must resolve to a specific zone, 10 that must resolve to nothing. Kota Kinabalu and Christmas
Island are load-bearing — both pass on the comprehensive upstream build and both fail on the merged
-1970 variant — so they are what stops a future data sync from quietly shipping Malaysian
territory as Indonesian.
Licensing
The code is MIT (LICENSE). Everything under resources/data/ is Open Database License
1.0 (DATA-LICENSE), because it derives from OpenStreetMap.
Timezone boundaries from timezone-boundary-builder, derived from OpenStreetMap data, © OpenStreetMap contributors, ODbL 1.0.
Keep that attribution with any copy you distribute. Returning a timezone for a coordinate in an API
response is a Produced Work and needs attribution only; redistributing the boundary file itself
keeps it under ODbL. The line also lives inside the GeoJSON's attribution property, so it survives
being copied out of this repository.