storyblok/symfony-bundle
Integrates the Storyblok headless CMS with Symfony, offering configuration, services, and helpers to fetch, manage, and render Storyblok content in Symfony applications with less boilerplate.
This is a Symfony bundle, not a Laravel package. storyblok/symfony-bundle boots via Symfony Flex and registers services through a StoryblokBundle + DI extension; there is no service provider, no config/storyblok.php, no facade. In a Laravel app the practical path is one of:
composer require storyblok/php-client and wire a singleton yourself (the bundle is a thin, opinionated wrapper around exactly that client), orIf you are in a Symfony app, the first steps are:
composer require storyblok/symfony-bundle
# accept the Flex recipe, then:
php bin/console config:dump-reference storyblok
Configure the minimum in config/packages/storyblok.yaml:
storyblok:
base_uri: 'https://api.storyblok.com/v2'
token: '%env(STORYBLOK_TOKEN)%'
api_parameters:
version: '%env(STORYBLOK_VERSION)%' # draft in dev, published in prod
Where to look first: config:dump-reference storyblok (the authoritative key list for your installed version), then the service definition for the client, then the Twig extension.
First use case: inject the client, fetch a story by slug, render content. This is the 20-line vertical slice that proves token, version and cache are all correct before you build anything else.
What changed in 1.18.2 — the two commits are a Dependabot CI bump and one bugfix: strip the language prefix from full_slug when the Translatable Slugs app is not installed. Patch-level, no config keys added or removed, no deprecations. It only matters if you resolve routes from full_slug on an i18n Storyblok space.
Version switching per environment. api_parameters.version is the single lever between draft and published. Bind it to APP_ENV rather than hardcoding, and never let draft reach production — draft responses bypass the CDN cache and expose unpublished content.
Catch-all routing from full_slug. The canonical Storyblok-CMS pattern: one wildcard route, resolve the story by slug, 404 on miss.
// Laravel equivalent of the bundle's routing story
Route::get('/{slug?}', function (StoryblokClient $client, string $slug = '') {
$story = $client->getStoryBySlug($slug); // returns false on 404
abort_unless($story, 404);
return view('storyblok.page', compact('story'));
})->where('slug', '.*');
This is exactly the code path affected by the 1.18.2 fix — see Gotchas.
Cache-then-invalidate, not cache-with-TTL-only. The bundle's real value is the pairing of a PSR-6 cache pool around the client with a webhook endpoint that busts it. Port both or neither; a TTL-only cache gives you stale content right after an editor hits publish, which is the #1 complaint you'll get. Config shape is roughly:
storyblok:
cache:
pool: 'cache.storyblok' # must be a real PSR-6 pool service id
ttl: 3600
webhook_secret: '%env(STORYBLOK_WEBHOOK_SECRET)%'
Point Storyblok's webhook at the bundle's route for story.published, story.unpublished, story.deleted, and make sure the secret matches — an unverified webhook route is a cache-poisoning vector.
Rich text as a first-class renderer. Use the bundle's storyblok_richtext filter / rich-text service instead of {!! $story['content']['body'] !!}. It walks the doc tree, so you can override rendering per node type (links with linktype: story need slug resolution against your route table, not the raw cached_url).
Block components mapped by component name. Every blok carries a component string. Convention: one partial per component name, with a default fallback that renders nothing in prod and logs in dev. Keep the mapping in one place so adding a blok is a one-file change.
Asset and image handling. Storyblok's image service is a URL transform (/m/{width}x{height}/filters:quality(...)). The bundle's asset transformer does this for you, but it only rewrites a.storyblok.com URLs — guard against passing external URLs through it.
Visual editor hooks. storyblok_editable (data attributes for the Storyblok bridge) should be applied only when a request is a preview — gate on version === 'draft' and an authenticated editor session, otherwise you leak draft metadata to anonymous traffic.
Testing. Fixture the raw CDN JSON (cdn/stories/{slug} response body) and assert your view layer against it. Stub the PSR-18 HTTP client or bind a fake client in the container; don't hit the API in tests — the published/draft distinction makes them flaky.
language / fallback_lang) and you do not have the Translatable Slugs app installed, full_slug was previously left with its language prefix (de/unternehmen); 1.18.2 strips it. Routes generated from full_slug therefore change shape for non-default languages. Audit your i18n spaces, your sitemap, and add 301s for the old prefixed URLs before deploying. If you do have the app installed, behaviour is unchanged.getStoryBySlug returns false, not an exception, on 404. A truthiness check is not optional. Symmetrically, network failures and 401s do throw — catch them separately so a bad token doesn't render as "page not found".full_slug. Storyblok returns slugs without a leading slash, but cached_url may include one, and the language-prefix handling complicates this further. Normalize once at the boundary (ltrim($slug, '/')) and never compare slugs from two different sources directly.version: draft disables CDN caching. Switching it on in production is both a performance cliff and a content-leak. Assert it in your deploy check.storyblok.cache.pool at a pool you also clear from elsewhere, you'll get surprise purges. Use a dedicated pool so cache:pool:clear <storyblok-pool> is surgical.league/html-to-markdown-style allowlists) if anyone outside your org can edit content. In Blade this means being deliberate about {!! !!}.if (!str_contains($url, 'a.storyblok.com')) return $url;.config:dump-reference storyblok for the schema, debug:config storyblok for effective values after env resolution, and a PSR-18 logging middleware (or Laravel's Http::withOptions + Log::debug) to see the exact CDN URL — most "wrong content" bugs are a wrong version, language or cv parameter.How can I help you explore Laravel packages today?