kijin/pinboard-api
A Laravel/PHP client for the Pinboard bookmarking API, making it easy to fetch, create, update, and delete bookmarks and tags with a clean, expressive wrapper. Ideal for syncing bookmarks or building Pinboard-powered tools.
kijin/pinboard-api is a thin, single-class PHP wrapper around the Pinboard.in v1 REST API (https://api.pinboard.in/v1/). It has no Laravel integration, no service provider, and effectively one dependency-free client object. Treat it as a low-level transport, not a framework package.
First steps
posts/add, posts/get, posts/recent, posts/all, posts/update, tags/get, tags/rename, tags/delete, user/api_token). Method names and argument order vary between versions, so confirm against vendor/kijin/pinboard-api/src/ rather than blog posts.https://pinboard.in/settings/password (the format is username:HEXTOKEN). Do not use your account password — Pinboard has deprecated password query params, and the package may still default to them.config/services.php and .env, not in code:// config/services.php
'pinboard' => [
'token' => env('PINBOARD_TOKEN'), // "user:ABCDEF..."
],
posts/update returns a single timestamp and mutates nothing — ideal for a smoke test in tinker:$client = new \Kijin\Pinboard\Pinboard(config('services.pinboard.token'));
$client->getLastUpdate(); // or the method wrapping posts/update
First real use case: a "save for later" button on an internal Laravel app. A controller accepts a URL + title, dispatches a queued job, and the job calls posts/add. That single flow exercises auth, tag formatting, the 3-second rate rule, and error handling — everything you need to learn before scaling up to sync.
Bind the client as a singleton behind your own interface. Never let the vendor class leak into controllers or jobs. Wrap it:
// app/Services/Pinboard/PinboardClient.php (interface)
interface PinboardClient {
public function add(string $url, string $title, array $options = []): void;
public function recent(int $count = 20): array;
public function tags(): array;
public function lastUpdate(): string;
}
// app/Providers/PinboardServiceProvider.php
$this->app->singleton(PinboardClient::class, fn () => new VendorPinboardAdapter(
config('services.pinboard.token')
));
This gives you one place to swap in Laravel's Http client if the package breaks, and one place to fake in tests.
Queue all writes. Pinboard explicitly asks clients to wait at least 3 seconds between API calls, and the package almost certainly does no throttling. Push mutations through jobs with a rate-limited queue:
// App\Jobs\AddBookmark
public function handle(PinboardClient $pinboard): void
{
$pinboard->add($this->url, $this->title, [
'tags' => implode(' ', $this->tags), // space-delimited string
'toread' => $this->unread,
'shared' => false, // be explicit; defaults are easy to misread
]);
}
// AppServiceProvider
RateLimiter::for('pinboard', fn () => Limit::perMinute(20)); // 1 per 3s
Apply ->middleware(['throttle:pinboard']) in the job constructor or use RateLimited job middleware.
Incremental sync with the posts/update watermark. Don't diff your posts with posts/all on every run. Store the timestamp from posts/update, then fetch changed posts. posts/all is paginated (start / results) and slow — cache it or run it on a nightly schedule only. Persist url, description, extended, tags, dt, hash, shared, toread, and meta locally so your UI never blocks on Pinboard.
Use the right read endpoint for the job:
| Need | Endpoint |
|---|---|
| Dashboard widget / "latest saves" | posts/recent (count up to 100) |
| Fetch metadata for a known URL | posts/get with url — returns meta plus matching posts |
| Tag autocomplete / tag cloud | tags/get (cheap, cache 10–15 min) |
| Rebuild local index | posts/all (paginated, slowest) |
| Detect changes | posts/update |
Cache aggressively on reads. Cache::remember('pinboard.tags', now()->addMinutes(15), ...) and similar for recent posts. Tags change rarely and the API is one request every three seconds — cache misses are expensive in wall time, not money.
Normalize URLs before saving. Strip utm_*, fbclid, fragments, and trailing slashes in a single normalizeUrl() helper. Pinboard treats URLs as keys; inconsistent normalization produces duplicates and posts/delete calls that don't hit the record you expected.
Handle errors in one adapter method. Wrap calls in try/catch, inspect both the HTTP status and the result field in the decoded JSON, and map:
401 / "result":"not authorized" → config error, alert, don't retry.429 / 503 → release the job with backoff ($this->release(60 * 15))."result":"done" → success — anything else means the call did not take effect even though HTTP was 200.Testing. Fake your own PinboardClient interface, not the vendor class. For an integration test against the real API, use a dedicated Pinboard account with a token in CI secrets, and expect it to be slow — one call per test case is the only sustainable pattern.
?username=&password= which Pinboard now rejects. If the client's constructor accepts a password, it may still work via auth_token; verify by checking the query string it builds.Log::debug($request->url()), Sentry breadcrumbs, and exception messages. Configure your request logger to strip auth_token and never dump the full URL.posts/add defaults are dangerous. replace defaults to yes, so re-adding an existing URL silently overwrites the title, notes, and tags you set earlier. If you want non-destructive saves, pass replace=no explicitly. Similarly, shared=yes makes a bookmark public — a typo in a boolean-to-string conversion can publish something you meant to keep private.dt must be ISO-8601 UTC and in the past. Use now()->utc()->format('Y-m-d\TH:i:s\Z'). Omitting dt means "now". Passing local time without the Z produces a shifted timestamp with no error.reading-list), and passing an array directly will likely be cast to "Array" in the query string.result is authoritative. HTTP 200 with {"result_code":"something went wrong"} is a failed call. Code that only checks the HTTP status will silently lose bookmarks.posts/get has no posts key when nothing matches; meta only appears for URLs already in the account. Always ?? [] and ?? null.posts/all does not tell you about deletions. Deleted bookmarks simply disappear. Diff only after a full sync, and prefer the posts/update watermark plus periodic full reconciliations rather than assuming posts/all is a complete snapshot every page.composer require may fail on PHP 8.x if composer.json pins php ^5.x or ^7.x. Check with composer why-not kijin/pinboard-api. If it's abandoned, the wrapper is small enough to replace with 40 lines of Http::withToken(...) — don't spend a day fighting Composer for a class you could rewrite in an hour.file_get_contents) will block the request until your PHP default_socket_timeout fires. Only ever call it from queued jobs or scheduled commands, never inline in a web request.withoutOverlapping() — a slow posts/all pagination run overlapping with the next tick is the quickest way to get your IP throttled.How can I help you explore Laravel packages today?