awcodes/postal-codes
A Laravel package for working with postal codes. It provides simple helpers and validation rules to format, normalize, and verify postal codes, helping you keep address data clean and consistent across your applications.
Architecture fit. awcodes/postal-codes is not a service and not a validator-as-a-service — it is a reference-data provider wrapped in a Laravel package: a service provider, publishable config, migration, Eloquent model, and a GeoNames TSV import path. There is no runtime call to GeoNames in production. That means it fits as an internal data layer inside the Laravel app (or behind a small internal API), not as a third-party integration in the architecture diagram. The correct architectural shape is a repository interface owned by our domain, with the package confined to one adapter class. If we adopt it any other way — controllers and form requests touching the model directly — we inherit the lock-in without the low-cost exit that MIT and a small surface area otherwise give us.
Strategically this package reframes a build-vs-buy decision: own a dataset instead of pay per lookup. It buys self-hosting, zero per-lookup cost, no customer data leaving our VPC, and no new DPA. It does not buy deliverability confidence, rooftop geocoding, or anyone's uptime SLA. That trade needs an explicit product owner decision, not an engineering one.
Integration feasibility. Code-level integration is genuinely small: composer require, publish config and migrations, migrate, run the import command for one country. The cost is not in the package — it is in the data operations we now own: import pipeline, refresh cadence, schema verification per driver, normalization policy, and attribution compliance. Realistic sizing:
| Workstream | Estimate |
|---|---|
| Timeboxed spike (install, one-country import, DDL verification) | 0.5–1 day |
| Repository adapter + DTO + fixture tests | 3–5 days |
| Import pipeline (chunked, transaction/staging swap, queued) | 3–5 days |
| Refresh job + version record + row-count guardrail | 2–3 days |
| Pilot market end-to-end in checkout | 1–2 weeks elapsed |
Technical risk.
maatwebsite/excel ^4.0 requires illuminate/support ^12.0 || ^13.0. A postal-code lookup table therefore sets our Laravel floor at 12/13. Any Laravel 10 or 11 app cannot install it at all. This is the kind of constraint that surfaces three sprints into a platform upgrade and should be raised in the architecture review, not discovered in a PR.Blueprint::float() to float($column, $precision = 53), so the package's float('lat', 10, 8) silently produced a 4-byte single-precision column on MySQL, PostgreSQL, and SQL Server. Coordinates like 40.71277800 were rounded at write time. The suite runs on SQLite, which stores a full double regardless — so green tests prove nothing about schema on the production driver. This is a process risk as much as a code risk: we need a DDL assertion against a real driver in CI, or "tests pass" becomes a false signal in release reviews.ALTER alone restores nothing. If we ever ship v1.0.1, we owe the business a data remediation plan.Key questions to resolve before commitment.
Stack fit. Laravel 12 or 13 only, on PHP matching those floors, with MySQL, PostgreSQL, SQL Server, or SQLite behind it. The driver matters — coordinate precision behavior differs, and SQLite will mask schema defects that MySQL and PostgreSQL will not. If our production driver is MySQL or PostgreSQL, the spike must verify DDL there, not locally.
Migration path. Two distinct scenarios with different costs:
Separately, if we ever run v1.0.1, the v1.0.2 upgrade is a planned data migration (widen + re-import), owned by whoever owns the data pipeline, scheduled like a schema change with a verification step.
Compatibility. Two hard constraints to check across the whole monorepo before sequencing anything:
maatwebsite/excel ^4.0 → illuminate/support ^12.0 || ^13.0. This is the gating constraint, and it comes from the spreadsheet library rather than the package itself. A lookup table pulling in an Excel library is heavier than "lightweight provider" suggests; note it in the dependency review.Sequencing. Six stages, each with a gate:
SHOW CREATE TABLE / information_schema confirms double / double precision for lat/lng on the production driver.PostalCodeRepository and our own PostalCodeData DTO. Package import stays inside one adapter. Gate: no package class names appear in controllers, form requests, or Blade.Non-negotiable design decisions to lock in stage 1–3, not later:
postal_code (as published/typed) and postal_code_normalized (uppercased, separators stripped), and index the normalized column. Never store a postal code as an integer — leading zeros in 01234 vanish silently.(country_code, postal_code). One code can map to many rows; a silent first() is a well-known source of wrong-region bugs. Decide up front whether the feature takes the first row or returns a list.Maintenance — we own data freshness; the package does not. The recurring obligations are concrete and must have a named owner:
^1.0.2), reviewed on a cadence, with the fork/vendor path kept warm.Support. No vendor SLA. Support is a GitHub issue tracker with one maintainer. That is acceptable only because the surface is small and MIT: if the maintainer disappears, we can vendor the code and continue. The mitigation is organizational — the adapter interface and our own DTO must exist before we depend on this in a revenue path, not after. Budget roughly a day a quarter for dependency and upstream review.
Scaling. Three levers, in order:
(country_code, postal_code_normalized); without it, every lookup is a scan.For proximity, a naive haversine is a full table scan. Prefilter with a bounding box (± radius/111 for latitude, adjusted by cos(lat) for longitude), then filter by great-circle distance. With v1.0.2's double precision this is defensible for zone assignment and distance sorting — and still city-level centroids, not rooftop.
Failure modes and their blast radius.
| Failure | Blast radius | Mitigation |
|---|---|---|
Narrow lat/lng columns (pre-1.0.2 schema) |
Silent, unrecoverable coordinate drift | DDL assertion in CI on a real driver; pin ^1.0.2 |
| Half-completed import | One market returns misses or wrong regions | Transactional or staging-table swap |
| Normalization mismatch (spaces, case) | "Code not found" for valid codes | Store normalized column; fixture tests with spaced and unspaced codes |
| Hard-failing checkout on dataset miss | Blocked paying customers | Soft-fail split; log-and-continue |
| Market with no postal codes (IE, HK, AE) | Legitimate checkouts rejected | Gate the field on our market config |
| Unbounded import through Eloquent | OOM / deploy timeout | Chunked raw inserts, queued job, explicit memory limit |
| Miss-rate creep in a supported market | Coverage gaps we don't see | Per-country miss-rate metric as the keep-vs-replace trigger |
The single most important operational control is the miss-rate metric per country. A climbing miss rate in a supported market is our earliest signal that coverage is thin or an import slice is wrong — and it is the number that decides keep-versus-replace in a future build-vs-buy review. Instrument it before the pilot exits, not after expansion.
Ramp-up. One backend engineer, roughly 2–3 weeks elapsed to production for the first market including the adapter, pipeline, refresh job, and fixtures. Each additional market is ~2 days of runbook execution. The learning curve is not in the package — it is in the data operations discipline and in the normalization rules per country (Canada and the UK use an internal space; the Netherlands uses 1234 AB; Germany has none).
Recommendation. Proceed to a one-day spike, contingent on three things being true: (1) the estate is on Laravel 12/13 or will be, (2) legal signs off on CC BY 4.0 attribution placement, and (3) we accept ownership of a quarterly refresh runbook with a named owner. Kill criteria at the spike gate: narrow-float schema that cannot be corrected on our driver, or a pilot market whose miss rate makes the dataset unusable for the intended product surface. Treat this as a narrowly-scoped capability behind an interface, not a strategic platform bet — the exit is cheap, and we should keep it cheap.
How can I help you explore Laravel packages today?