pokoblog / laravel
Laravel integration for the PokoBlog article API and publish webhook.
Requires
- php: >=8.2
- illuminate/cache: ^11.0 || ^12.0 || ^13.0
- illuminate/contracts: ^11.0 || ^12.0 || ^13.0
- illuminate/http: ^11.0 || ^12.0 || ^13.0
- illuminate/routing: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
- pokoblog/client: ^1.1
- symfony/http-kernel: ^7.0 || ^8.0
Requires (Dev)
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A thin layer over pokoblog/client. It does not reimplement the
client; it adds the four things a plain PHP library cannot give you: config that
publishes, a webhook route in one line, a cache that uses the API's ETag, and
an event to listen for.
Installing
composer require pokoblog/laravel php artisan vendor:publish --tag=pokoblog-config
Requires PHP 8.2+ and Laravel 11, 12 or 13. The provider and the PokoBlog facade
are auto-discovered.
POKOBLOG_URL=https://app.pokoblog.example POKOBLOG_TOKEN=your-embed-connector-token POKOBLOG_WEBHOOK_SECRET=whsec_…
Reading the blog
use PokoBlog\Laravel\Facades\PokoBlog; Route::get('/blog', function () { return view('blog.index', ['articles' => PokoBlog::page(20)->articles]); }); Route::get('/blog/{slug}', function (string $slug) { return view('blog.show', ['article' => PokoBlog::article($slug)]); });
{{-- blog/show.blade.php --}} <h1>{{ $article->title() }}</h1> {{-- `html` is PokoBlog's sanitized output. Unescaped on purpose; see below. --}} {!! $article->html !!}
{!! !!} rather than {{ }} because html is markup on purpose: it is the
output of PokoBlog's allowlist renderer and is the identical string PokoBlog
writes into a WordPress post. Escaping it shows the visitor its own tags.
markdown is not interchangeable here — it is the unsanitized source and
accepts raw HTML by design.
The facade resolves the cached client. Inject
PokoBlog\Laravel\CachedPokoBlog for the same thing where you would rather not
use a facade; PokoBlog::client() reaches the plain client underneath.
Paging
page(20) is one page, not the blog. Past twenty articles the route above shows
the newest twenty and the rest are unreachable.
Use a page number, which is what a reader sees and the only thing a numbered pager can be built from:
Route::get('/blog', function (Request $request) { $asked = (int) $request->query('page', 1); return view('blog.index', [ 'page' => PokoBlog::pageAt(max(1, $asked), 20), ]); });
@if ($page->pages > 1) <nav aria-label="Paginering"> @if ($page->number > 1) <a href="/blog?page={{ $page->number - 1 }}" rel="prev">Nieuwere</a> @endif @for ($n = 1; $n <= $page->pages; $n++) <a href="{{ $n === 1 ? '/blog' : '/blog?page=' . $n }}" @if ($n === $page->number) aria-current="page" @endif>{{ $n }}</a> @endfor @if ($page->number < $page->pages) <a href="/blog?page={{ $page->number + 1 }}" rel="next">Oudere</a> @endif </nav> @endif
$page->pages and $page->total come back on every listing, so "pagina 3 van
12" needs no second request. $page->number is the page you asked for, and null
when you paged by cursor -- a cursor carries no position.
Cursors, for walking the whole blog
page($limit, $cursor) and pageBefore($prevCursor, $limit) are still there and
are the right tool for a sitemap, a static export, or an append-only "load more".
An offset shifts if an article publishes while you page, so a walk can repeat or
miss one; a cursor means "after this exact article" and cannot.
For a blog index that shift is one article appearing on two pages, about once a day. That is a fair price for a URL a reader can share and Google can index.
Two details worth getting right
A page past the end is an empty list with an honest pages, not an error --
redirect or say so rather than rendering a blank screen.
Canonicalise a paged URL to itself. /blog?page=2 gets
<link rel="canonical" href=".../blog?page=2">, not one pointing at page one:
that asks Google to drop every page but the first, and the articles listed only
on them go with it.
$limit is 1 to 50. Above 50 the API answers 422 rather than clamping.
Each page is cached under its own key, including the two that hold the same articles reached from different directions -- they carry different cursors, and sharing one entry would hand a reader the wrong links.
The sitemap
Every article, not one page of them. PokoBlog::articles() pages until there is
nothing left, so this holds the whole blog however big it gets.
Route::get('/sitemap.xml', function () { $xml = Cache::remember('blog-sitemap', now()->addDay(), function () { $site = rtrim(config('app.url'), '/'); $lines = ['<?xml version="1.0" encoding="UTF-8"?>']; $lines[] = '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'; $lines[] = ' <url><loc>' . e($site . '/blog') . '</loc></url>'; foreach (PokoBlog::articles() as $article) { $lines[] = ' <url>' . '<loc>' . e($site . '/blog/' . $article->slug) . '</loc>' . '<lastmod>' . e($article->modified) . '</lastmod>' . '</url>'; } $lines[] = '</urlset>'; return implode("\n", $lines); }); return response($xml, 200, ['Content-Type' => 'application/xml; charset=utf-8']); });
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 it. The walk is the whole blog, and a crawler should not set it going
on every hit. The publish webhook already calls PokoBlog::flush(); give the
sitemap its own Cache::forget('blog-sitemap') there, or accept a day's delay.
Walk with articles() rather than pageAt(): 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.
The caching is the point
Cache::remember('blog', 300, fn () => $poko->page()) is the obvious version and
it throws away what makes this API cheap: every five minutes it re-downloads the
whole list whether or not a word of it changed, and a blog is written to a few
times a week and read continuously.
This package uses the ETag instead. Two lifetimes, both in
config/pokoblog.php:
ttl(300s) — served with no HTTP request at all.hold(86400s) — how long the entry survives after that, so its tag is still there to revalidate with.
When a stale entry is refreshed, its tag goes out as If-None-Match and an
unchanged blog answers 304 with no body: a round trip and a header block
instead of an article list. hold shorter than ttl would throw the entry away
before it could ever be revalidated — that is refused in the constructor rather
than warned about, because its only symptom is a bandwidth bill nobody reads.
It stores arrays, so serializable_classes is not your problem
Laravel 13 ships 'serializable_classes' => false in config/cache.php, and
every store that serializes then reads with allowed_classes => false. A
package that cached its own objects would come back as
__PHP_Incomplete_Class on the second page view — and the upgrade guide's other
option is asking you to add its class names to your allowlist, which is a
security setting you should not have to widen to install a blog.
So nothing here caches an object. Page and ArticleBody go in through
toArray() and come back through fromArray(), which works on every store,
under any serializable_classes setting, and across a deploy that changes a
class. An entry left by an older release is treated as a cache miss and
refetched, so upgrading needs no cache:clear.
Receiving a publish
// routes/api.php Route::pokoblog('/webhooks/pokoblog');
That is the whole endpoint. The signature is verified before anything runs; a
call that fails gets a bare 403.
// app/Providers/AppServiceProvider.php use Illuminate\Support\Facades\Event; use PokoBlog\Laravel\Events\ArticlePublished; use PokoBlog\Laravel\Facades\PokoBlog; Event::listen(function (ArticlePublished $published) { // The blog stops serving yesterday's list this second, not at the TTL. PokoBlog::flush(); // $published->slug(), $published->event->article->title, … // $published->delivery() is stable across retries: use it as an // idempotency key. });
flush() is a version counter in the cache key, not cache tags. Tags exist on
Redis and Memcached and not on the file or database stores most small sites run,
so a tag-based flush would silently do nothing for most customers and be found
on the day somebody published a correction and the old copy stayed up.
Register it in routes/api.php
Or exclude the URI from CSRF. The web group verifies a CSRF token and PokoBlog
has none to send, so a route registered there answers 419 to every delivery —
which reads as an authentication problem and is not one.
// bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: ['webhooks/pokoblog']); })
Queue anything slow
The route answers 204 as soon as the event is dispatched, which is the
contract PokoBlog documents — it holds a customer's publish request open for at
most five seconds while it waits. A listener that resizes images should
implement ShouldQueue.
If a delivery is lost
PokoBlog tries three times over about ten seconds and then stops. Reconcile from
PokoBlog::articles() on a slow timer; the article list is the source of truth
and this event is the fast path, not a guarantee.
A 500 you might see once
If POKOBLOG_WEBHOOK_SECRET is not set, the first delivery raises a
LogicException naming that key rather than quietly refusing every call — from
PokoBlog's side an endpoint that rejects everything looks exactly like a
firewall problem, and you would debug the wrong end for a day.
Tests
clients/laravel/bin/test clients/laravel/bin/test --filter test_a_publish_drops_what_was_cached
Real Testbench, real routes, real cache stores; the transport is faked so nothing leaves the process. Uses containers when there is no local PHP.