storyblok/php-management-api-client
A PHP SDK for Storyblok's Management API that lets you manage spaces, stories, components, and assets programmatically from Laravel or any PHP app, simplifying authenticated requests to Storyblok's headless CMS endpoints.
Install and authenticate. Two different Storyblok tokens exist and mixing them up is the single most common first-day failure:
storyblok/php-client for rendering content. Useless here.composer require storyblok/php-management-api-client
STORYBLOK_MANAGEMENT_TOKEN=...
STORYBLOK_SPACE_ID=123456
The space ID is the numeric ID from Settings → General in the space, not the space slug (my-space). Almost every endpoint is scoped to it.
Look here first. The package is small (13 stars, thin wrapper). On install, spend five minutes reading src/ — specifically the client constructor and one resource class — before writing code. That tells you: does it hold the space ID internally or require it per call, does it return raw arrays or a response object, and does it expose the underlying Guzzle instance. The method names in the examples below are indicative; confirm them there.
First use case — verify auth and shape with a read-only script:
$client = new Client(config('services.storyblok.management_token'));
$space = $client->spaces()->get($spaceId); // proves the token works
$stories = $client->stories()->all($spaceId); // proves scoping + pagination shape
dump($space, array_slice($stories, 0, 2));
Once that prints, you have everything you need to judge how much wrapping it deserves.
Bind it once, never new it inline. In a Laravel app, register a singleton in a service provider and inject it. Configure timeouts and retries there, not at call sites:
$this->app->singleton(Client::class, fn () => tap(
new Client(config('services.storyblok.management_token')),
fn ($c) => $c->setSpaceId(config('services.storyblok.space_id')),
));
If the package allows access to the Guzzle client, add a middleware that logs method + path + status + duration. You will need this the first time a bulk operation half-fails.
Use it for admin and build-time work, not page rendering. The natural fits are: content migration from a legacy CMS, bulk publish/unpublish across a release, generating stories from a product feed, seeding a staging space from production, and schema-as-code in CI. If you find yourself calling it inside a web request that renders a page, you want the CDN client instead.
Wrap bulk reads in a generator. Pagination is page-based with a hard per_page cap (typically 100). Don't hand-roll the loop at every call site:
function allStories(Client $client, int $spaceId): Generator
{
for ($page = 1; ; $page++) {
$batch = $client->stories()->all($spaceId, ['page' => $page, 'per_page' => 100]);
if (empty($batch)) {
return;
}
yield from $batch;
if (count($batch) < 100) {
return; // last page
}
}
}
Make importers idempotent and keyed on full_slug, not slug. Fetch the existing story set once into a ['full_slug' => id] map, then create-or-update per record. This turns a re-runnable migration into a two-line change and saves you from duplicate stories every time the job is retried. Compare full_slug because slug collides across folders.
Push the work onto the queue with a rate limiter. Bulk write jobs should be chunked (Bus::batch over 100-item chunks), with a Redis::throttle('storyblok-mgmt')->allow(5)->every(1) guard or a job middleware. A few thousand creates fired in a loop will get you throttled and produce a partially-migrated space.
Component schemas are the sharp edge of CI usage. Components are JSON blobs; storing one file per component in the repo and diffing on deploy is genuinely valuable, but see the gotchas below.
Testing. If it's Guzzle underneath, inject a MockHandler and assert on the request path rather than mocking the client class — you get free coverage of your own wrapper logic. If not, put the client behind your own StoryblokAdmin interface and fake that.
.env you push to staging. Rotate it in Personal access tokens and expect old tokens to die instantly.['story' => [...]], some return a bare list. Dump array_keys() once per resource method rather than assuming.publish: 1 on a create is not the same as publishing an existing draft; there's a distinct publish action. Also remember edits go to the draft version — code that "updates" a story without publishing leaves the live site unchanged.full_slug is derived. Moving a parent folder silently rewrites children's full_slugs. Anything you key on full_slug needs re-syncing after structural changes.pos) matters for the editor UI, and duplicate pos values produce a confusing editor. If you generate schemas in code, assign pos explicitly.lang set is an alternate; creating the same slug in another language is a separate story with translated_slugs links. Bulk logic that ignores lang will overwrite translations.422 bodies usually name the offending field; 403 is almost always token scope, not endpoint permissions; 429 means you should slow down rather than retry immediately.composer.json, and keep all calls behind a single service class so swapping to raw Guzzle/Saloon later is a one-file change rather than a grep across the codebase.How can I help you explore Laravel packages today?