Skip to content

Commit 0af30d5

Browse files
author
Oliver Kingston
committed
Initial Postio PHP SDK (v0.1.0)
- PostioClient (sync) — PHP's async story is fragmented (ReactPHP/Amphp/ Fibers); SDK customers want blocking calls - 6 endpoints mirror @postio/core: $client->address->search/postcode/udprn, $client->email->validate, $client->phone->validate, $client->connect() - Hand-written readonly value objects (final classes) for every response - Typed exception hierarchy: PostioException base + 9 subclasses with status, errorCode, details, requestId, envelope - Default retry (2x exp backoff full jitter on 408/409/429/5xx + network) - Guzzle 7 transport, inject your own PSR-18 ClientInterface via $http - 12 tests pass: 8 offline (MockHandler) + 4 live against stage - CI: PHPUnit matrix on PHP 8.1/8.2/8.3/8.4 - No release workflow; Packagist auto-indexes new tags via GH webhook
0 parents  commit 0af30d5

37 files changed

Lines changed: 1528 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [stage, master]
6+
pull_request:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
test:
13+
runs-on: ubuntu-latest
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
php: ["8.1", "8.2", "8.3", "8.4"]
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- uses: shivammathur/setup-php@v2
22+
with:
23+
php-version: ${{ matrix.php }}
24+
coverage: none
25+
tools: composer:v2
26+
27+
- name: Composer cache
28+
uses: actions/cache@v4
29+
with:
30+
path: ~/.composer/cache
31+
key: composer-${{ matrix.php }}-${{ hashFiles('composer.json') }}
32+
33+
- name: Install deps
34+
run: composer install --no-interaction --no-progress --prefer-dist
35+
36+
- name: PHPUnit (offline)
37+
run: vendor/bin/phpunit --testsuite offline
38+
39+
live-test:
40+
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
41+
runs-on: ubuntu-latest
42+
needs: test
43+
steps:
44+
- uses: actions/checkout@v4
45+
46+
- uses: shivammathur/setup-php@v2
47+
with:
48+
php-version: "8.3"
49+
tools: composer:v2
50+
51+
- name: Install deps
52+
run: composer install --no-interaction --no-progress --prefer-dist
53+
54+
- name: Live tests against stage
55+
env:
56+
POSTIO_API_KEY_STAGE: ${{ secrets.POSTIO_API_KEY_STAGE }}
57+
run: vendor/bin/phpunit --testsuite live

.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/vendor/
2+
composer.lock
3+
.phpunit.result.cache
4+
.phpunit.cache/
5+
.php-cs-fixer.cache
6+
.phpstan.cache/
7+
.DS_Store
8+
.env
9+
.env.*
10+
!.env.example

CHANGELOG.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Changelog
2+
3+
All notable changes to `postio/postio` are documented here. Format follows
4+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning
5+
follows [SemVer](https://semver.org/).
6+
7+
## [Unreleased]
8+
9+
## [0.1.0] — 2026-05-02
10+
11+
Initial release. First Postio PHP SDK on Packagist.
12+
13+
### Added
14+
15+
- `Postio\PostioClient` with sync API (PHP's async story is too
16+
fragmented to ship two surfaces).
17+
- Address: `$client->address->search/postcode/udprn`.
18+
- Email: `$client->email->validate`.
19+
- Phone: `$client->phone->validate`.
20+
- Health probe: `$client->connect()`.
21+
- Hand-written `readonly` value objects for every response shape.
22+
- Typed exception hierarchy: `PostioException` base +
23+
`PostioInvalidKeyException`, `PostioOutOfCreditException`,
24+
`PostioForbiddenException`, `PostioNotFoundException`,
25+
`PostioValidationException`, `PostioRateLimitException`,
26+
`PostioServerException`, `PostioTimeoutException`,
27+
`PostioConnectionException`. Each carries `status`, `errorCode`,
28+
`details`, `requestId`, `envelope`.
29+
- Default retry policy (2 retries, exp backoff + full jitter on
30+
408/409/429/5xx + network/timeout). Mirrors `@postio/node`.
31+
- Guzzle 7 as the HTTP transport — inject your own `ClientInterface`
32+
via the `$http` constructor arg for proxies, mocks, custom middleware.
33+
- `POSTIO_API_KEY` env var fallback when no `apiKey` argument is passed.
34+
35+
### Notes
36+
37+
- `PhoneResult.isReachable` is typed `bool|string|null` because the
38+
live API returns booleans there even though the spec says
39+
string-only. Aligned once postio-api ships a spec/runtime fix.
40+
- Property `errorCode` (not `code`) — PHP's `Exception::$code` is a
41+
built-in non-readonly int and can't be shadowed by a readonly
42+
string.
43+
44+
[Unreleased]: https://github.com/postio-uk/postio-php/compare/v0.1.0...HEAD
45+
[0.1.0]: https://github.com/postio-uk/postio-php/releases/tag/v0.1.0

CLAUDE.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# postio-php — Claude Code working notes
2+
3+
PHP SDK for `postio-api`. Mirrors `@postio/core` with idiomatic PHP
4+
ergonomics. Lives in its own repo because PHP's Composer/Packagist
5+
toolchain doesn't co-exist with the umbrella's pnpm workspace.
6+
7+
Read [`README.md`](./README.md) for the customer-facing surface; this
8+
file is the operational guide.
9+
10+
## Stack
11+
12+
- **PHP 8.1+**. Constructor property promotion, readonly properties,
13+
named arguments, enums, never type. We deliberately stay on 8.1 as
14+
the floor for max install share.
15+
- **HTTP**: Guzzle 7 via PSR-18-compatible `ClientInterface`. Customers
16+
can inject their own client (for proxies, mocks, middleware).
17+
- **Tests**: PHPUnit 10. Offline tests use Guzzle's `MockHandler`;
18+
live tests are gated to a separate `live` test suite that's only
19+
run when a stage key is in env.
20+
21+
## Why no codegen
22+
23+
`jane-php/open-api-runtime` exists, but the generated code is
24+
heavyweight (DTO + normalizer + denormalizer per type) and the spec
25+
surface is small. Hand-written `readonly` value objects with a static
26+
`fromArray` factory are simpler, faster, and easier to debug.
27+
28+
## Layout
29+
30+
```
31+
postio-php/
32+
├── composer.json
33+
├── phpunit.xml
34+
├── src/
35+
│ ├── PostioClient.php sync client, retry loop, error mapping
36+
│ ├── Resource/
37+
│ │ ├── AddressResource.php
38+
│ │ ├── EmailResource.php
39+
│ │ └── PhoneResource.php
40+
│ ├── Model/ every response shape
41+
│ └── Exception/ one class per HTTP failure mode
42+
├── tests/
43+
│ ├── PostioClientTest.php offline (MockHandler)
44+
│ └── Live/LiveTest.php live (skipped if no key in env)
45+
├── README.md / CLAUDE.md / LICENSE / CHANGELOG.md
46+
└── .github/workflows/ci.yml
47+
```
48+
49+
## Common commands
50+
51+
```bash
52+
composer install
53+
vendor/bin/phpunit # offline + live
54+
vendor/bin/phpunit --testsuite offline # offline only
55+
set -a && source ../.env && set +a && vendor/bin/phpunit --testsuite live
56+
```
57+
58+
## Branch + deploy model
59+
60+
- `stage` — working branch.
61+
- `master` — push triggers the live-test job.
62+
- Releases: tag `vX.Y.Z` + push tag. Packagist auto-detects the new
63+
tag via the GitHub webhook (configured at first
64+
`packagist.org/packages/submit`) and indexes it within ~1 minute.
65+
- No release workflow file needed. Packagist is the registry; tags
66+
are the publish trigger. There's no auth-time secret to manage.
67+
68+
## Spec drift
69+
70+
`PhoneResult` carries two manual patches (the spec says
71+
`required` for every nullable field, and types `isReachable` as
72+
string-only when the API returns bools). Mirror of postio-python's
73+
patches. CHANGELOG.md notes them. Reapply if PhoneResult is ever
74+
regenerated.
75+
76+
## Secrets the CI needs
77+
78+
| Secret | Used by |
79+
|---|---|
80+
| `POSTIO_API_KEY_STAGE` | live-test job in `ci.yml`. Pair with `stage-api.postio.co.uk` (handled by the live test). |
81+
82+
No publish secret. Packagist is webhook-driven.
83+
84+
## Tone for this repo
85+
86+
Same as the umbrella: terse, casual, status-emoji summaries.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Onno Group Limited (trading as Postio)
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Postio PHP SDK
2+
3+
[![Packagist](https://img.shields.io/packagist/v/postio/postio.svg)](https://packagist.org/packages/postio/postio)
4+
[![PHP Version](https://img.shields.io/packagist/php-v/postio/postio.svg)](https://packagist.org/packages/postio/postio)
5+
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6+
7+
PHP SDK for the [Postio API](https://postio.co.uk) — UK address, email, and
8+
phone validation. Backed by Royal Mail PAF and Ordnance Survey. PSR-18 over
9+
Guzzle, typed `readonly` value objects.
10+
11+
## Install
12+
13+
```bash
14+
composer require postio/postio
15+
```
16+
17+
Requires PHP 8.1+.
18+
19+
## 30-second example
20+
21+
```php
22+
<?php
23+
24+
require 'vendor/autoload.php';
25+
26+
use Postio\PostioClient;
27+
28+
$client = new PostioClient(apiKey: 'pk_live_...'); // or set POSTIO_API_KEY
29+
30+
$result = $client->address->search('downing street');
31+
foreach ($result->results as $hit) {
32+
echo $hit->udprn . ': ' . $hit->suggestion . PHP_EOL;
33+
}
34+
35+
echo 'request id: ' . $result->meta->requestId . PHP_EOL;
36+
```
37+
38+
## API
39+
40+
| Method | Returns |
41+
|---|---|
42+
| `$client->address->search($q, $maxResults)` | `AddressSearchEnvelope` |
43+
| `$client->address->postcode($postcode, $maxResults)` | `AddressPostcodeEnvelope` |
44+
| `$client->address->udprn($udprn)` | `AddressUdprnEnvelope` |
45+
| `$client->email->validate($address)` | `EmailEnvelope` |
46+
| `$client->phone->validate($number)` | `PhoneEnvelope` |
47+
| `$client->connect()` | `ConnectSuccess` |
48+
49+
## Errors
50+
51+
Every non-2xx response throws a typed exception. `PostioException` is the
52+
base, with subclasses per status code:
53+
54+
```php
55+
use Postio\Exception\PostioInvalidKeyException;
56+
use Postio\Exception\PostioOutOfCreditException;
57+
use Postio\Exception\PostioRateLimitException;
58+
59+
try {
60+
$client->address->postcode('not-a-postcode');
61+
} catch (PostioInvalidKeyException $e) {
62+
// 401
63+
} catch (PostioOutOfCreditException $e) {
64+
// 402
65+
} catch (PostioRateLimitException $e) {
66+
// 429 — $e->retryAfter has the suggested wait in seconds
67+
}
68+
```
69+
70+
Every exception carries `status`, `errorCode`, `details`, `requestId`, and
71+
`envelope`. Quote `requestId` when reporting issues.
72+
73+
## Configuration
74+
75+
```php
76+
$client = new PostioClient(
77+
apiKey: 'pk_live_...',
78+
baseUrl: 'https://api.postio.co.uk/v1', // default
79+
timeout: 10.0, // seconds
80+
retries: 2, // 0 to disable
81+
headers: ['x-tracking-id' => '...'], // extra headers
82+
);
83+
```
84+
85+
Default retry policy: 2 retries on 408/409/429/5xx + network/timeout,
86+
exponential backoff with full jitter (500ms → 8s cap).
87+
88+
## Frameworks
89+
90+
The SDK is framework-agnostic. Inject `PostioClient` once via your
91+
container:
92+
93+
**Laravel** — bind in a service provider:
94+
95+
```php
96+
$this->app->singleton(PostioClient::class, fn () => new PostioClient(
97+
apiKey: config('services.postio.key'),
98+
));
99+
```
100+
101+
**Symfony** — register as a service in `services.yaml`:
102+
103+
```yaml
104+
Postio\PostioClient:
105+
arguments:
106+
$apiKey: '%env(POSTIO_API_KEY)%'
107+
```
108+
109+
## Links
110+
111+
- [Docs](https://postio.co.uk/docs)
112+
- [API reference (OpenAPI)](https://postio.co.uk/openapi.json)
113+
- [Changelog](./CHANGELOG.md)
114+
- [Issues](https://github.com/postio-uk/postio-php/issues)
115+
116+
## License
117+
118+
MIT — see [LICENSE](./LICENSE).
119+
120+
> *Postio is a trading name of Onno Group Limited, registered in
121+
> England & Wales (company no. 08622799). Registered office:
122+
> Suite 22 Trym Lodge, 1 Henbury Road, Westbury-On-Trym, Bristol BS9 3HQ.*

0 commit comments

Comments
 (0)