Search by

pokoblog / client

rbouma

PHP client for the PokoBlog article API and publish webhook.

Package info

github.com/HeyPoko/pokoblog-php

pkg:composer/pokoblog/client

Statistics

Installs: 11

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.2.0 2026-09-06 14:40 UTC

This package is auto-updated.

Last update: 2026-11-07 03:57:46 UTC


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.

Paging by number

$page = $poko->pageAt(3, limit: 20);

echo "Pagina {$page->number} van {$page->pages}";  // Pagina 3 van 12

pages and total come back on every listing, so a numbered pager can draw itself without a second request. This is the paging a reader sees, and the only kind a "1 2 3 ... 12" can be built from -- a cursor only ever answers "what comes after this one".

The trade is that an offset shifts if an article publishes while somebody is paging, so one article can appear on two pages. For a blog that is roughly once a day, against a URL a reader can share.

Paging by cursor, for walking the whole blog

$page = $poko->page(limit: 20);

// Older, and null once there are none.
if ($page->nextCursor !== null) {
    $older = $poko->page(limit: 20, cursor: $page->nextCursor);
}

// Newer, and null on the first page.
if ($page->prevCursor !== null) {
    $newer = $poko->pageBefore($page->prevCursor, limit: 20);
}

Two cursors, because one is a dead end: with only nextCursor an index can offer "older" and has nothing to put behind "newer", so a reader on page three can only start again. Each is null at its own end, so the same pair of ifs renders one link, two, or none.

Prefer these over pageAt() when nobody sees the URL and the walk must not skip a row: a sitemap, a static export, articles(). An offset can repeat or miss an article across a publish; a cursor cannot.

$limit is 1 to 50. Above 50 the API answers 422 rather than clamping.

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.

Putting a page somewhere that is not memory

Page, ArticleBody and Article each have a toArray(), and it is the exact shape their fromArray() reads:

$page = $poko->page(limit: 20);

$cache->set('blog', [
    'data'   => $page->toArray(),
    'limit'  => $page->limit,
    'cursor' => $page->cursor,
    'etag'   => $page->etag,   // keep it, or you cannot revalidate
]);

// … later …
$page = Page::fromArray($e['data'], $e['limit'], $e['cursor'], $e['etag']);

Store the array, not the object. A serialized object is a serialized class shape, so a warm entry written before an upgrade rehydrates wrong — and on Laravel 13, whose cache defaults to allowed_classes => false, it does not rehydrate at all. limit, cursor and etag ride alongside because they are the request and a response header, not part of what the endpoint answered.

The sitemap

Every article, not one page of them. articles() pages until there is nothing left, so a sitemap built from it holds the whole blog however big it gets.

$site = 'https://example.com';
$lines = ['<?xml version="1.0" encoding="UTF-8"?>'];
$lines[] = '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';

foreach ($poko->articles() as $article) {
    $lines[] = sprintf(
        '  <url><loc>%s</loc><lastmod>%s</lastmod></url>',
        htmlspecialchars("$site/blog/{$article->slug}", ENT_XML1),
        htmlspecialchars($article->modified, ENT_XML1),
    );
}

$lines[] = '</urlset>';

file_put_contents('public/sitemap.xml', implode("\n", $lines));

modified is the article's last write, which is what lastmod means. Do not use published -- a corrected article keeps its publish date, and a crawler told nothing changed will not come back for it.

Cache or write it to a file rather than building it per request: the walk is the whole blog. Rebuild it from the publish webhook, or on a schedule.

Use articles() rather than pageAt() here. An offset can repeat or miss an article if something publishes mid-walk, and a sitemap that quietly omits one is the kind of bug nobody notices.

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_decode then json_encode changes 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.