pokoblog / client
PHP client for the PokoBlog article API and publish webhook.
Requires
- php: >=8.1
- ext-curl: *
- ext-json: *
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Read a customer's published articles, and verify the webhook PokoBlog sends when it publishes one. No framework, no build step, PSR-4.
Installing
composer require pokoblog/client
Requires PHP 8.1+, ext-json and ext-curl.
Reading the blog
use PokoBlog\PokoBlog; $poko = new PokoBlog( baseUrl: 'https://app.pokoblog.example', // where PokoBlog is served token: 'your-embed-connector-token', // Connections → Embed ); // A blog index. One request; a card needs no second call. foreach ($poko->page(limit: 20) as $article) { printf( '<article><h2><a href="/blog/%s">%s</a></h2><p>%s</p></article>', htmlspecialchars($article->slug), htmlspecialchars($article->title), htmlspecialchars((string) $article->excerpt), ); } // One article. `html` is already sanitized -- echo it, do not escape it. $article = $poko->article('wat-magento-onderhoud-kost'); echo '<h1>' . htmlspecialchars($article->title()) . '</h1>'; echo $article->html;
Every article, not the first fifty
foreach ($poko->articles() as $article) { // Pages are followed for you. Lazy: `break` and it stops fetching. }
This is a generator, and it is the method to use whenever you want "all of them" — a sitemap, a static build, an import. There is deliberately no method returning the whole blog as one array, because the only honest implementations of that are this walk or the first page pretending to be all of it.
The walk is a consistent snapshot. An article published while it is running
lands in front of the walk and is not seen by it; it arrives on the next walk.
That is what a build wants, and it is why paging here is a cursor rather than
?page=2 — over a list that grows at the front, offsets serve one article twice
and never serve another, and report neither.
Not re-downloading a blog that has not changed
$page = $poko->page(limit: 20); // … later, from a cache … $fresh = $poko->revalidatePage($page); if ($fresh === $page) { // 304. Nothing was transferred; carry on with what you had. }
PokoBlog sends a strong ETag over the exact bytes. revalidatePage() and
revalidateArticle() send it back as If-None-Match and hand you the same
object when the server says nothing changed, so === is the whole check. If
you are on Laravel, pokoblog/laravel does this for you.
Receiving a publish
use PokoBlog\Webhook; use PokoBlog\Exception\InvalidSignatureException; $body = Webhook::rawBody(); // php://input, unparsed try { $event = Webhook::parse( secret: getenv('POKOBLOG_WEBHOOK_SECRET'), // the whole whsec_… string header: Webhook::signatureFrom(), // Poko-Signature rawBody: $body, ); } catch (InvalidSignatureException) { http_response_code(403); exit; } http_response_code(204); // answer first, work afterwards flush(); if ($event->isPublished()) { // $event->article->slug, ->title, ->image, ->imageAlt … // $event->delivery is stable across retries: use it as an idempotency key. }
Three things this gets right that a hand-written verifier usually does not, and they are all worth copying rather than reimplementing:
- The signature is over the raw bytes.
json_decodethenjson_encodechanges whitespace and key order, and the signature is over bytes. This is by a distance the commonest reason a correct-looking verifier rejects every call. - The timestamp is inside the HMAC, and calls more than five minutes old are refused in both directions. Without that, a call captured once can be replayed for as long as the secret lives, verifying perfectly every time.
- The comparison is
hash_equals.==and===stop at the first differing byte, so how long they take measures how much of the correct signature the caller already has — and someone who can make you verify repeatedly recovers the rest a byte at a time and can then forge publishes.
It is a notification, not the record
PokoBlog tries three times over about ten seconds and then gives up. A deploy
that overlaps a publish loses the call. That is survivable only because the
article list is the source of truth: reconcile from articles() on a slow timer
and you will find anything a delivery missed.
Errors
| Exception | When |
|---|---|
NotFoundException |
404 — the address reaches nothing |
InvalidRequestException |
422 — a page size out of range, a bad cursor |
ApiException |
any other non-2xx; ->errorCode is the stable part |
MalformedResponseException |
a 200 that is not our JSON — usually a wrong base URL |
TransportException |
no answer at all |
InvalidSignatureException |
a webhook that is not ours, or is old |
All of them extend PokoBlogException.
A 404 means the address reaches nothing and deliberately nothing more specific: a rotated token, a disconnected embed connector, a draft's slug and a slug that never existed all answer it, which is what stops a stranger confirming a draft's address by asking for it. When one surprises you, check the token first — and note that disconnecting the embed connector switches this API off as well, because the widget and the JSON API are the same connector row.
Tests
clients/php/bin/test # everything clients/php/bin/test --filter "constant_time" # one test
Uses local php and composer when they exist, and containers when they do
not, so the suite runs on a machine that has only ever built the TypeScript half
of PokoBlog. Override with POKOBLOG_PHP_IMAGE and POKOBLOG_COMPOSER_IMAGE.