Post offices, parcel lockers and post boxes, as an API, for PHP. Ask what is nearest a coordinate, look up what a postcode covers, or read a suburb's locations.
- Free and keyless. No account, no quota to buy, nothing to configure
- No dependencies: ext-curl by default, or bring your own transport (WordPress, Laravel, any PSR-18 client) in a few lines
- PHP 8.1 and up, with readonly value objects for the rows you use most
- Australia, New Zealand, the United States, the United Kingdom and Canada
composer require postfinder/postfinderuse PostFinder\Client;
$pf = new Client();
$near = $pf->nearby(-37.7404, 144.9633, 'post-offices', 'australia');
foreach ($near as $place) {
echo $place->name, ', ', $place->address, ', ', $place->metres(), " m\n";
}Nearest first, within 50km, at most 30 rows. That is the question "where do I post this", which is a different question from "list every post box in Victoria".
Client::CATEGORIES;
// post-offices, post-boxes, express-post-boxes, parcel-lockers,
// drop-off-points, collection-pointsA name outside the six throws InvalidArgumentException before any request
goes out.
$hits = $pf->search('coburg', limit: 8);
foreach ($hits as $hit) {
echo $hit->kind, ' ', $hit->name, ' ', $hit->postcode, ' ', $hit->path(), "\n";
}Two characters minimum: below that it returns [] without asking, which is what
the service answers anyway. Behind a search box, debounce by at least 150ms.
Every row carries the slugs its page path is built from, so no second request is needed:
use PostFinder\Paths;
$hit->path(); // /en/australia/victoria/coburg/
$place->path(); // /en/australia/victoria/coburg/australia-post/coburg-post-office-uv5h25cs/
Paths::locality('australia', 'victoria', 'coburg');A location's path carries its brand as a segment, and a place with no brand is
filed under unbranded. A search row does not carry the brand, so a place hit
links to its suburb; place() returns the exact path.
A postcode is not a suburb. 3058 is Coburg and Coburg North, and an address in either is written with the same four digits.
$detail = $pf->postcode('australia', '3058');
array_column($detail['localities'], 'name'); // ['Coburg', 'Coburg North']The whole country comes in one response, and it is meant to be kept:
$index = $pf->postcodes('australia'); // one request
$index->suburbsIn('3058'); // no request, and no rescansuburbsIn builds its map on first use and keeps it on the index, so resolving a
column of ten thousand postcodes is one pass over the country rather than ten
thousand walks through every state. Cache the index (APCu, Redis, a file) rather
than fetching it per request.
$detail = $pf->place('uv5h25cs'); // PlaceDetail: place, nearby, reviews, photos
$suburb = $pf->locality('australia', 'victoria', 'coburg');
$pf->countries();
$pf->country('australia');
$pf->region('australia', 'victoria', limit: 100, offset: 0);
$pf->category('australia', 'parcel-lockers', region: 'victoria');A place's public id is permanent. It is minted once and never derived from a source record, so a feed that renumbers its rows does not change it. Store the id, not the name or the path.
SearchHit, NearbyPlace, Place and PlaceDetail are readonly objects, and
each keeps every field the service sent in $raw, so a field added to the API
later is readable before this package names it. The suburb, region, category and
postcode calls return the decoded arrays, with their shapes documented for
PHPStan and Psalm.
use PostFinder\Exception\NotFound;
use PostFinder\Exception\RateLimited;
try {
$detail = $pf->place($storedId);
} catch (NotFound) {
$detail = null; // retired, or never there
}NotFound is ordinary rather than a failure: a suburb with no locations has no
page, and a location that closed is retired. Every error extends
PostFinderException and carries the status, title and detail the service
sent; RateLimited carries retryAfter when the service said. No answer at all
(a timeout, a refused connection) is a PostFinder\Http\TransportException.
The client needs one method, so a framework's HTTP client fits behind it:
use PostFinder\Http\Response;
use PostFinder\Http\Transport;
final class WordPressTransport implements Transport
{
public function get(string $url, array $headers): Response
{
$res = wp_remote_get($url, ['headers' => $headers, 'redirection' => 0, 'timeout' => 15]);
if (is_wp_error($res)) {
throw new \PostFinder\Http\TransportException($res->get_error_message());
}
return new Response(
(int) wp_remote_retrieve_response_code($res),
array_change_key_case((array) wp_remote_retrieve_headers($res)->getAll()),
(string) wp_remote_retrieve_body($res),
);
}
}
$pf = new Client(transport: new WordPressTransport());A transport must not follow redirects and must hand back every status rather
than throwing on 4xx and 5xx. The default CurlTransport does both, verifies
TLS, refuses any scheme but http and https, and stops reading past 8 MiB.
The API is free and asks for care in return: around a thousand requests a month from one address, answers kept rather than fetched again, typing debounced. If you need more, say what you are building at postfinder.io/en/contact/.
$pf = new Client(contact: 'https://example.com/about-our-bot');Locations come from OpenStreetMap (ODbL), localities and postcodes from GeoNames (CC BY 4.0). If you publish what you get back, you carry those credits with it. The sources page names each one.
| PHP | postfinder/postfinder |
| JavaScript and TypeScript | @postfinder/client |
| React | @postfinder/react |
| Vue | @postfinder/vue |
| Python | postfinder |
| Go | postfinder-go |
MIT.