Search by

pokoblog / laravel

rbouma

Laravel integration for the PokoBlog article API and publish webhook.

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

This package is auto-updated.

Last update: 2026-11-07 05:47:24 UTC


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.