Skip to content

Address geocoding

Address geocoding

US sales tax stacks rates below the state (county, city, special district), so a state alone is not enough — the address must be resolved to a taxing jurisdiction. The AddressGeocoder contract is that seam.

The shipped GeocodioGeocoder resolves US and Canada addresses via Geocodio API v2. Set an API key to bind it:

// config/tax.php  (or .env: GEOCODIO_API_KEY=...)
'geocodio' => [
    'key' => env('GEOCODIO_API_KEY'),
],
use Cbox\Tax\Contracts\AddressGeocoder;

$jurisdiction = app(AddressGeocoder::class)->locate([
    'line1' => '1600 Amphitheatre Pkwy',
    'city' => 'Mountain View',
    'subdivision' => 'CA',
    'postalCode' => '94043',
    'country' => 'US',
]);
// -> resolved Cbox\Geo Jurisdiction (US-CA), or null

Two rules the design keeps:

  • We take only geocoding from Geocodio — country and state/province (plus the county names, ZIP+4 and coordinates described below). Geocodio offers no sales-tax or taxing-jurisdiction append, and none is wanted: the rate and the calculation stay in this engine, so it remains authoritative and the adapter swappable.
  • Deny-by-default. Any failure — no key, request error, unparseable result, a state that does not resolve — returns null. Never a ZIP-centroid guess.

A failure is retried once before it is believed. Geocodio answers 403 Invalid API key intermittently on a valid key (observed twice in roughly ten calls while this adapter was built), and with rooftop enabled an unresolved address is not a degraded rate but a failed assessment — JurisdictionNotResolved. A genuinely invalid key just costs one extra request.

API version

The adapter targets v2 (https://api.geocod.io/v2). Of v2's breaking changes only two touch it — the response no longer carries a top-level input key beside results, and address_components.state became state_province — and both key spellings are read, so passing a v1.x baseUrl to the constructor still resolves:

new GeocodioGeocoder($http, $geo, $key, 'https://api.geocod.io/v1.7');

Two further v2 changes do not affect this adapter: zip became postal_code (unread), and Canadian results now echo the full postal code where the FSA matches instead of returning the FSA alone. The census append is unchanged between versions.

Timeouts

A checkout waits on the geocoder. Set tax.geocodio.timeout and tax.geocodio.connect_timeout (GEOCODIO_TIMEOUT, GEOCODIO_CONNECT_TIMEOUT, in seconds) to bound each request; unset, the HTTP client's 30-second default applies. A failed request is retried once, and each attempt gets the full timeout.

Address-level resolution

Enable tax.geocodio.rooftop (GEOCODIO_ROOFTOP=true) and sync the boundary artifacts to attach ZIP+4 or point localities. A Jurisdiction carries one locality, chosen for the state's resolution path. The path is read from the installed register, not from a list in the package, so a state that gains polygons or a county-level answer in a new release is picked up by the next tax:data:sync:

State (release 293) Locality attached Resolved by
a state the register publishes polygons for — California, New Mexico, Texas a point, scheme latlng (34.052200,-118.243700) the register's polygon layer, read from each state's own GIS
the 24 Streamlined states a ZIP+4, scheme zip9 (66101-3064) the register's boundary index, via RegisterBoundaries
a postal state with a district overlay — Nebraska the ZIP+4 and the point, scheme zip9+latlng ([email protected],-96.215563) the boundary index, then the districts drawn over it
a state whose resolution says county — Florida, Hawaii, Pennsylvania, Virginia county or independent-city name, scheme county register jurisdiction names; works with rooftop disabled
other states none state share, flagged where local tax may be missing

A county name matches however the register writes it: "Honolulu County" finds "City and County of Honolulu", and "Hawaii County" finds "County of Hawaii", never the state of Hawaii. A named unit matches only the same unit, because Virginia has four names that are both a county and an independent city: "Richmond County" is not "Richmond City". A name that matches two jurisdictions is refused, not guessed.

Where the register lists the county-equivalents it prices at nothing local (unpriced, from release 303), a listed name — "Roanoke city" — is the state share, certain. A name found neither among the priced places nor in that list stays the state share, flagged: an unmatched name is not knowledge.

Coordinates come back on every Geocodio result; the ZIP+4 needs the zip4 append, which the adapter requests when rooftop is on:

Field Example (701 N 7th St, Kansas City KS)
fields.zip4.zip9 ["66101-3064"] — the USPS add-on
address_components.postal_code 66101 — the ZIP5 alone, not enough

A ZIP+4 is a postal key, not a taxing authority. The register's boundary index expands it into the authorities that apply, and the rate source sums them — see register coverage. A point needs no such expansion: it is real geography, and the polygon it falls in identifies the authorities whose rates the register supplies.

tax:data:sync --streets=KS,WA additionally installs street indexes. It does not enable the geocoder's rooftop option; the shipped geocoder emits the locality keys in the table. Parsed street addresses can be resolved through RegisterBoundaries::resolveParsed().

Two refusals worth knowing. Geocodio returns zip9 as a list; an address spanning several add-ons could straddle a jurisdiction line, so no locality is attached rather than one being picked. And ZIP5 alone is never used: 54% of Washington's ZIP5s and 91% of Kansas' span more than one jurisdiction set.

An earlier version attached a county FIPS from the census append instead. It could never resolve — it names one authority where several may apply, and Geocodio's code is state-prefixed where the dataset's are not.

Without a key the contract is left unbound. Bind your own AddressGeocoder if you use a different provider.