storyblok/php-content-api-client
A PHP client for Storyblok's Content Delivery and Management APIs, giving Laravel and plain PHP apps typed access to stories, components, datasources, and assets. Includes request handling, pagination, caching support, and a fluent query builder for headless content.
Install with Composer and confirm the HTTP transport is available:
composer require storyblok/php-content-api-client
Instantiate the client with the Content Delivery API token from Storyblok (Settings → Access Tokens). Use the public token for production and the preview token for draft content:
use Storyblok\Client;
$client = new Client($_ENV['STORYBLOK_TOKEN']);
// preview/draft content requires the *preview* token:
$client->setVersion('draft');
Your first useful call is getStories() for a folder listing, or getStory() for a single page. Both return a Storyblok\ApiResponse, not a raw array — call getBody() (or access it like an array, since the response implements ArrayAccess):
$response = $client->getStory('about');
$story = $response->getBody()['story']; // full story envelope
$content = $story['content']; // the component tree
$list = $client->getStories(['starts_with' => 'blog/'])->getBody()['stories'];
Where to look first:
Client class — every endpoint is a thin wrapper (getStories, getStory, getLinks, getDatasourceEntries, getTags, getSpace).Storyblok\ApiResponse — the only thing you need to understand to get data out (getBody(), getHeaders(), getStatusCode(), getResponse()).$params through verbatim, so Storyblok's own documentation is the client's documentation for filtering, pagination, and resolution.A typical first integration: a catch-all route/controller that maps a slug to getStory($slug) and a partial-per-component renderer. That is the minimum viable Storyblok site.
Wrap the client in a service, not a global. Bind a single configured instance in your container (or use Storyblok\ClientFactory::create($token)), then inject Client into repositories/controllers. Keep token retrieval, version selection, and resolveLinks()/resolveRelations() defaults in that factory so every call site behaves consistently.
// config/services.php / bootstrap
$client = new Client(config('storyblok.token'));
$client->setVersion(config('storyblok.version')) // 'published' | 'draft'
->resolveLinks('url'); // resolve link fields to hrefs
Repository layer returning plain arrays. Convert ApiResponse to arrays at the boundary. Domain code should never touch a PSR-7 response. This also makes caching trivial because arrays serialize cleanly.
final class StoryRepository
{
public function __construct(private Client $client) {}
public function bySlug(string $slug): ?array
{
$res = $this->client->getStory($slug);
return $res->getStatusCode() === 200 ? $res->getBody()['story'] : null;
}
public function folder(string $prefix, int $page = 1, int $perPage = 100): array
{
return $this->client->getStories([
'starts_with' => $prefix,
'page' => $page,
'per_page' => $perPage,
'sort_by' => 'content.published_at:desc',
])->getBody();
}
}
Pagination and headers. Storyblok caps per_page at 100 (default 25). The total counts live in the response headers, so read them rather than looping until an empty page if you need an accurate count:
$res = $client->getStories(['starts_with' => 'blog/', 'per_page' => 100, 'page' => 1]);
$total = (int) ($res->getHeaders()['total'][0] ?? 0);
Relation resolution is two-step. Collect UUIDs from your component fields and pass them back on a second request, or set them on the client before the call:
$client->resolveRelations('uuid-a,uuid-b'); // or an array, depending on version
$story = $client->getStory('blog/post')->getBody()['story'];
For lists, collect UUIDs across all returned stories, then call getStories(['by_uuids' => implode(',', $ids)]) or re-request with resolve_relations set. The client does not recursively walk the tree for you.
Navigation via getLinks(). Fetch once per layout, key by uuid, and use it to build menus and breadcrumbs without N+1 story lookups.
Caching with webhook invalidation. Cache the getBody() payload under a key that includes token scope, version, locale, slug/prefix, and a hash of the params. In Laravel, use tagged cache keys or a versioned prefix bumped by a Storyblok webhook on story.published / story.unpublished:
$key = "sb:{$version}:{$locale}:story:{$slug}";
$story = Cache::remember($key, now()->addHour(), fn () => $repo->bySlug($slug));
Localisation. Pass language (and optionally fallback_lang) as query params per request, and cache each locale separately. Don't mutate a shared client's language between requests in a long-running worker — set it explicitly per call or clone the client.
Rendering. This package is data-only. Keep a map of component name → template/Blade partial and recurse through content.body arrays. Unknown components should render a visible placeholder in dev and nothing in prod.
Testing. Point the client's endpoint at a local stub, or inject a PSR-18 mock client and return fixture JSON captured from real API responses. Fixtures from the Storyblok UI ("JSON" tab) are the fastest path to realistic tests.
setVersion('draft') with a public token returns 401/404 rather than an obvious error. Keep two configured clients (a cached production one, an uncached preview one) and never expose the preview token to a browser.ApiResponse object. It wraps a consumed PSR-7 response/stream and will not serialize usefully. Always extract getBody() first.getStory() on a missing slug can throw a ClientException/HTTP exception before you can inspect the status code. Wrap lookups in try/catch if you need "not found → 404 page" behaviour, or install a non-throwing HTTP client.story can be null, stories can be []. Guard for isset($body['story']); don't assume the key exists on failed-but-200 responses.filter_query is Storyblok's own mini-language (content.price:gt:100, content.tag:in:['a','b']) and is not validated client-side. A malformed query silently returns unfiltered or empty results — test these against the API directly before blaming the client.resolveLinks('url') vs 'story' vs '0'/'1' produce different shapes (href string vs full story array). Pick one convention project-wide or your templates will fork.setVersion, setLanguage, resolveRelations, and resolveLinks mutate the instance. Reset (or re-resolve from the container) at the start of each request/job.per_page above 100 is silently clamped, so pagination loops based on "fewer results than requested" terminate early and skip data. Use the total header with an explicit page counter.cv (cache version) query param can be used to bust Storyblok's CDN when a webhook fires; without it you may serve stale content for a short window even after publishing.Client for per-endpoint helpers is fine, but keep the transport concerns in the injected client.$response->getResponse() exposes the underlying PSR-7 request/response — log the final URI to see exactly which params the client serialised. That resolves 90% of "why is my filter ignored" questions immediately.How can I help you explore Laravel packages today?