Validate and geocode Australian addresses in PHP

About 15 minutes. Updated 11 September 2026.

A small PHP client for the Locio API that checks whether an address is real, returns its G-NAF id and coordinates, and falls back to fuzzy matching for typos. Plain curl, no framework required, with a Laravel variant.

On the server the question is different from autocomplete. You already hold an address, from a checkout form, a CRM or a spreadsheet, and want to know whether it is real, where it is, and what its parts are. /v1/addresses/resolve answers all three in one call, because they are one question.

Use a secret key here, read from the environment. A secret key can call every endpoint and works from anywhere, so it never goes in a page; that is what public keys are for.

01A small client

Plain curl, which every PHP install has. Errors come back as RFC 7807 problem documents with a detail written for a person, so the exception carries that sentence and the status.

src/Locio.phpphp
<?php
// src/Locio.php
declare(strict_types=1);

final class Locio
{
    private const API = 'https://api.locio.com.au';

    public function __construct(private readonly string $key) {}

    /**
     * The address as someone typed or stored it, in one string. Returns the
     * resolved address, or null when G-NAF has no such address.
     */
    public function resolve(string $address): ?array
    {
        $data = $this->get('/v1/addresses/resolve', ['q' => $address]);
        return $data['matched'] ? $data['address'] : null;
    }

    /** Close matches for an address with typos in it. Three units a call. */
    public function similar(string $address, int $limit = 5): array
    {
        return $this->get('/v1/addresses/similar', ['q' => $address, 'limit' => $limit]);
    }

    /** An address read back by the id you stored. */
    public function byId(string $gnafPid): ?array
    {
        try {
            return $this->get('/v1/addresses/' . rawurlencode($gnafPid));
        } catch (LocioException $e) {
            if ($e->status === 404) return null;   // G-NAF retired the id; search again
            throw $e;
        }
    }

    private function get(string $path, array $query = []): array
    {
        $url = self::API . $path . ($query ? '?' . http_build_query($query) : '');
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 5,
            CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->key, 'Accept: application/json'],
        ]);
        $body = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $error = curl_error($ch);
        curl_close($ch);

        if ($body === false) {
            throw new LocioException(0, "could not reach the API: $error");
        }
        $json = json_decode($body, true);
        if ($status >= 400) {
            // RFC 7807: the detail is written for a person to read.
            throw new LocioException($status, $json['detail'] ?? $json['title'] ?? "the API answered $status");
        }
        return $json['data'];
    }
}

final class LocioException extends RuntimeException
{
    public function __construct(public readonly int $status, string $message)
    {
        parent::__construct($message);
    }
}

02Validate, geocode, parse

matched says whether the address exists in G-NAF. When it does, the answer carries the id, the coordinates and every component. When it does not, the usual reason is a typo, and /v1/addresses/similar finds the nearest real addresses to offer back.

checkout.phpphp
<?php
require 'src/Locio.php';

$locio = new Locio(getenv('LOCIO_KEY'));   // a secret key, kept on the server

$address = $locio->resolve('1 george st sydenham nsw 2044');

if ($address === null) {
    // Not a real address as written. Offer the nearest ones instead.
    foreach ($locio->similar('1 george st sydenham nsw 2044') as $candidate) {
        echo $candidate['formatted'], PHP_EOL;
    }
    exit;
}

// Validated, geocoded and parsed, from one call.
echo $address['gnaf_pid'], PHP_EOL;                       // store this against your record
echo $address['lat'], ', ', $address['lng'], PHP_EOL;     // straight onto a map
echo $address['components']['postcode'], PHP_EOL;
echo $address['mesh_block'] ?? '', PHP_EOL;               // the ABS census join key

03Handle the two refusals worth handling

402 means the period's units are spent, 429 means too many requests in one second. Both are cheap to tell apart by status, and neither is charged.

php
try {
    $address = $locio->resolve($input);
} catch (LocioException $e) {
    match ($e->status) {
        402 => $log->warning('Locio quota is spent until the period resets'),
        429 => usleep(250_000),          // one plan's requests a second; back off and retry
        default => $log->error($e->getMessage()),
    };
}

04In Laravel

php
// Laravel: the same calls through the Http facade.
use Illuminate\Support\Facades\Http;

$res = Http::withToken(config('services.locio.key'))
    ->timeout(5)
    ->get('https://api.locio.com.au/v1/addresses/resolve', ['q' => $input]);

$data = $res->throw()->json('data');
$address = $data['matched'] ? $data['address'] : null;

05Cleaning a file

The same client, once per row. A thousand rows is a thousand units, which the free tier covers ten times over.

clean.phpphp
<?php
// Clean a spreadsheet: one line in, one line out, with the id and coordinates.
$in = fopen('customers.csv', 'r');
$out = fopen('customers-resolved.csv', 'w');
fputcsv($out, ['input', 'matched', 'gnaf_pid', 'formatted', 'lat', 'lng']);

while (($row = fgetcsv($in)) !== false) {
    $address = $locio->resolve($row[0]);
    fputcsv($out, [
        $row[0],
        $address ? 'yes' : 'no',
        $address['gnaf_pid'] ?? '',
        $address['formatted'] ?? '',
        $address['lat'] ?? '',
        $address['lng'] ?? '',
    ]);
}

Related