
This article presents an opinionated yet effective approach to integrating Laravel with Storyblok,...
This article presents an opinionated yet effective approach to integrating Laravel with Storyblok, focusing on practical decisions that unlock a real-time visual editing experience.
When you think of headless CMS integrations with real-time visual editing, frameworks like React, Vue, or Next.js usually come to mind. But what if I told you that Laravel with Blade templates can deliver the same seamless visual editing experience?
In this article, I'll walk you through how to integrate Storyblok's Visual Editor with Laravel, complete with real-time preview updates and smart DOM diffing to eliminate flickering. No JavaScript framework required.
<img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lk54vtlyxadvf8chc59r.gif" alt="The Laravel frontend application running in the Storyblok Visual Editor">Before diving into the code, let's address the elephant in the room: "Isn't Storyblok designed for JavaScript frameworks?"
Not at all. Storyblok is framework-agnostic. While the most common documentation emphasizes React and Vue, the underlying APIs work with any technology that can:
Laravel excels at all three. Plus, you get:
There's a reason Laravel keeps winning.
It's not just about elegant syntax or good documentation. It's about the ecosystem. Laravel has become a complete platform for building scalable, production-grade applications, with first-class packages for nearly everything you need.
Specifically, for content-driven frontends, you get Blade components that render fast and deploy anywhere. But here's what most developers miss: Laravel is now a serious platform for AI integration. Libraries like Laravel AI and Neuron AI let you build intelligent agents, connect to LLMs, and add AI-powered features without leaving the ecosystem you already know. And with NativePHP, you can ship native desktop and mobile apps using the same Laravel codebase.
Laravel isn't just for building websites. It's for building channels like kiosks, mobile applications, AI-powered assistants, and everything in between.
This matters for what we're about to build. A headless CMS integration isn't just about rendering pages. It's about creating a content layer that can feed your website today, your mobile app tomorrow, and your AI agent next month. Laravel handles all of it.
Now, let's talk about visual editing.
Here's how the integration works:
┌──────────────────────────────────────────────────────────────────┐
│ Storyblok CMS │
│ ┌────────────────────┐ │
│ │ Visual Editor │ │
│ └─────────┬──────────┘ │
│ ┌──────┴──────┐ │
│ Preview URL Real-time events │
│ │ │ │
└───────────────────────┼─────────────┼────────────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ Laravel Application │
│ │
│ ┌───────────────────┐ ┌───────────────────────────┐ │
│ │ StoryController │ │ Storyblok Bridge │ │
│ │ POST /api/preview│ │ (JavaScript) │ │
│ └─────────┬─────────┘ └─────────────┬─────────────┘ │
│ │ │ │
│ │ ┌────────────────────────┘ │
│ │ │ fetch('/api/preview') │
│ │ │ + Idiomorph DOM diffing │
│ ▼ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Blade Components │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │
│ │ │hero-section │ │ grid-card │ │ image (responsive) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────────┘ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │
│ │ │ richtext │ │article-page │ │ newsletter-form │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────────┘ │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
Two flows from the Visual Editor:
Preview URL → When you open a page in the Visual Editor, Storyblok loads your Laravel app via the configured preview URL. The StoryController fetches the story and renders it with Blade components.
Real-time events → As editors make changes, the Storyblok Bridge (JavaScript) receives events, sends the updated story JSON to /api/preview. The Preview controller renders the json with blade components and the JS receive the updated HTML and uses Idiomorph to update only the changed DOM elements.
Start with a fresh Laravel installation:
laravel new storyblok-laravel
cd storyblok-laravel
Install the Storyblok PHP SDK (for Content Delivery API):
composer require storyblok/php-content-api-client
Add your Storyblok credentials to .env:
STORYBLOK_ACCESS_TOKEN=your_preview_token_here
STORYBLOK_VERSION=draft
In config/serivces.php file add the Storyblok configuration:
'storyblok' => [
'access_token' => env('STORYBLOK_ACCESS_TOKEN'),
'version' => env('STORYBLOK_VERSION', 'published'),
],
Storyblok's Visual Editor requires your preview URL to be served over HTTPS, even during local development. This is a security requirement because the editor runs inside Storyblok's iframe, and modern browsers block mixed content.
The easiest solution is to use Vite's mkcert plugin with a proxy to your Laravel backend.
Install the mkcert plugin:
bun add -d vite-plugin-mkcert
# or if you prefer to use npm:
# npm install -D vite-plugin-mkcert
Update your vite.config.js:
import { defineConfig } from "vite";
import laravel from "laravel-vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import mkcert from "vite-plugin-mkcert";
const host = "127.0.0.1";
const port = "8000";
export default defineConfig({
plugins: [
mkcert(),
laravel({
input: ["resources/css/app.css", "resources/js/app.js"],
refresh: true,
}),
tailwindcss(),
],
server: {
https: true,
proxy: {
"^(?!(\/\\@vite|\/resources|\/node_modules))": {
target: `http://${host}:${port}`,
},
},
host,
port: 5173,
hmr: { host },
watch: {
ignored: ["**/storage/framework/views/**"],
},
},
});
How this works:
mkcert 127.0.0.1 to create a proper certificate and key for enabling SSLhttps://127.0.0.1:5173http://127.0.0.1:8000Start both servers:
composer run dev
Now configure your Storyblok space's Preview URL to:
https://127.0.0.1:5173/story/
Note: The first time you visit the HTTPS URL, your browser will warn about the self-signed certificate. Click "Advanced" → "Proceed" to accept it. You only need to do this once per browser session.
Register the Storyblok client as a singleton:
// app/Providers/StoryblokServiceProvider.php
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Storyblok\Api\StoriesApi;
use Storyblok\Api\StoryblokClient;
class StoryblokServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->singleton(StoryblokClient::class, function ($app) {
return new StoryblokClient(
"https://api.storyblok.com/v2/cdn/",
config("services.storyblok.access_token"),
);
});
$this->app->singleton(StoriesApi::class, function ($app) {
return new StoriesApi(
$app->make(StoryblokClient::class),
config("services.storyblok.version"),
);
});
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}
The controller handles both regular page loads and preview requests:
// app/Http/Controllers/StoryController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Storyblok\Api\StoriesApi;
use Storyblok\Api\Request\StoryRequest;
class StoryController extends Controller
{
public function __construct(private StoriesApi $storiesApi) {}
public function show(string $slug = "home")
{
$response = $this->storiesApi->bySlug($slug, new StoryRequest());
return view("story", ["story" => $response->story]);
}
public function preview(Request $request)
{
$story = $request->input("story");
if (!$story) {
return response()->json(["error" => "No story provided"], 400);
}
return view("story", ["story" => $story]);
}
}
The preview method is the key to real-time editing. When editors make changes in Storyblok, the Visual Editor sends the updated story JSON to this endpoint, and we return freshly rendered HTML.
Create a component resolver that maps Storyblok component names to Blade views:
{{-- resources/views/components/storyblok/component.blade.php --}}
@props(['blok'])
@php
$componentName = str_replace('_', '-', $blok['component'] ?? 'unknown');
@endphp
@if(View::exists('components.storyblok.' . $componentName))
<x-dynamic-component :component="'storyblok.' . $componentName" :blok="$blok" />
@else
<div class="alert alert-warning">
<span>Component "{{ $componentName }}" not found</span>
</div>
@endif
Now creating components is straightforward. Here's an example hero section:
{{-- resources/views/components/storyblok/hero-section.blade.php --}}
@props(['blok'])
<section {!! \App\Services\StoryblokEditable::attributes($blok) !!}
class="hero min-h-[500px] bg-base-200">
<div class="hero-content flex-col lg:flex-row gap-8">
@if(!empty($blok['image']['filename']))
<x-storyblok.image
:image="$blok['image']"
sizes="(max-width: 640px) 100vw, 384px"
:widths="[384, 512, 640, 768]"
:ratio="4/3"
class="max-w-sm rounded-lg shadow-2xl"
loading="eager"
fetchpriority="high"
/>
@endif
<div>
<h1 class="text-5xl font-bold">{{ $blok['headline'] ?? '' }}</h1>
@if(!empty($blok['text']))
<p class="py-6">{{ $blok['text'] }}</p>
@endif
</div>
</div>
</section>
Notice the StoryblokEditable::attributes($blok) call. This adds the data-blok-c and data-blok-uid attributes that Storyblok's Visual Editor needs to identify which component you're clicking on.
Here's the JavaScript that enables real-time preview:
{{-- resources/views/components/storyblok/bridge.blade.php --}}
@if(request()->has('_storyblok') || request()->has('_storyblok_tk'))
<script src="https://unpkg.com/[email protected]/dist/idiomorph.min.js"></script>
<script>
(function() {
const script = document.createElement('script')
script.src = 'https://app.storyblok.com/f/storyblok-v2-latest.js'
script.async = true
script.onload = function() {
const storyblokInstance = new window.StoryblokBridge()
async function updatePreview(story) {
try {
const response = await fetch('/api/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ story }),
})
const html = await response.text()
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
const newMain = doc.querySelector('main')
const currentMain = document.querySelector('main')
if (newMain && currentMain) {
Idiomorph.morph(currentMain, newMain, {
morphStyle: 'innerHTML',
ignoreActiveValue: true,
// head: { style: 'merge' }
})
}
} catch (error) {
console.error('Preview error:', error)
}
}
storyblokInstance.on('input', (event) => {
if (event.story) {
updatePreview(event.story)
}
})
storyblokInstance.on(['published', 'change'], () => {
window.location.reload()
})
}
document.head.appendChild(script)
})()
</script>
@endif
The naive approach would be:
currentMain.innerHTML = newMain.innerHTML
But this causes flickering because every element gets destroyed and recreated, even if nothing changed. Images reload, animations restart, and form inputs lose focus.
Idiomorph (created by the htmx team) performs intelligent DOM diffing. It:
The result? Buttery smooth real-time preview.
Storyblok includes a powerful Image Service that can resize, crop, and optimize images on the fly. Let's create a component that leverages it:
{{-- resources/views/components/storyblok/image.blade.php --}}
@props([
'image',
'sizes' => '100vw',
'widths' => [400, 600, 800, 1200, 1600, 2000],
'ratio' => null,
'class' => '',
'loading' => 'lazy',
'fetchpriority' => null,
'quality' => 80,
'smart' => false,
])
@php
$filename = $image['filename'] ?? '';
$alt = $image['alt'] ?? '';
$title = $image['title'] ?? '';
$focus = $image['focus'] ?? '';
if (empty($filename)) {
return;
}
/**
* Build Storyblok Image Service URL
* @see https://www.storyblok.com/docs/api/image-service
*
* Format: {filename}/m/{width}x{height}/smart/filters:{filter1}:{filter2}
* - /m/ prefix enables WebP conversion
* - {width}x{height} for resize (use 0 for auto, e.g., 800x0)
* - /smart for smart cropping (face detection)
* - /filters:focal(x,y) for focal point cropping
* - /filters:quality(0-100) for compression
*/
$buildUrl = function ($width) use ($filename, $ratio, $focus, $quality, $smart) {
$height = $ratio ? round($width / $ratio) : 0;
$dimensions = "{$width}x{$height}";
$url = "{$filename}/m/{$dimensions}";
// Add smart cropping if enabled (uses face detection)
if ($smart && $height > 0) {
$url .= "/smart";
}
// Build filters
$filters = [];
// Focal point filter (only applies when cropping, i.e., height > 0)
// Storyblok focus format: "leftX:leftY:rightX:rightY" or "pointX:pointY"
if ($focus && $height > 0 && !$smart) {
$filters[] = "focal({$focus})";
}
// Quality filter
if ($quality && $quality < 100) {
$filters[] = "quality({$quality})";
}
if (!empty($filters)) {
$url .= "/filters:" . implode(':', $filters);
}
return $url;
};
// Build srcset with multiple widths
$srcset = collect($widths)->map(function ($width) use ($buildUrl) {
return "{$buildUrl($width)} {$width}w";
})->implode(', ');
// Default src (medium size fallback)
$defaultWidth = $widths[2] ?? 800;
$src = $buildUrl($defaultWidth);
@endphp
@if($filename)
<img
src="{{ $src }}"
srcset="{{ $srcset }}"
sizes="{{ $sizes }}"
alt="{{ $alt }}"
@if($title) title="{{ $title }}" @endif
@if($loading) loading="{{ $loading }}" @endif
@if($fetchpriority) fetchpriority="{{ $fetchpriority }}" @endif
decoding="async"
@class([$class])
/>
@endif
This component:
srcset with multiple resolutionsfetchpriority="high" for LCP images💡 Hint: once you understand the dynamics of rendering the image via the Storyblok Image Service with PHP, you can evaluate using this package in your project: https://github.com/storyblok/php-image-service
Storyblok stores rich text as a JSON document structure. Here's a service to convert it to HTML:
// app/Services/StoryblokRichtext.php
<?php
namespace App\Services;
class StoryblokRichtext
{
public static function render(array|null $content): string
{
if (!$content) return '';
return self::renderNode($content);
}
private static function renderNode(array $node): string
{
$type = $node['type'] ?? '';
$content = '';
foreach ($node['content'] ?? [] as $child) {
$content .= self::renderNode($child);
}
return match ($type) {
'doc' => $content,
'paragraph' => "<p>{$content}</p>",
'heading' => "<h{$node['attrs']['level']}>{$content}</h{$node['attrs']['level']}>",
'bullet_list' => "<ul>{$content}</ul>",
'ordered_list' => "<ol>{$content}</ol>",
'list_item' => "<li>{$content}</li>",
'blockquote' => "<blockquote>{$content}</blockquote>",
'text' => self::renderText($node),
default => $content,
};
}
private static function renderText(array $node): string
{
$text = e($node['text'] ?? '');
foreach ($node['marks'] ?? [] as $mark) {
$text = match ($mark['type']) {
'bold' => "<strong>{$text}</strong>",
'italic' => "<em>{$text}</em>",
'link' => "<a href=\"{$mark['attrs']['href']}\">{$text}</a>",
default => $text,
};
}
return $text;
}
}
You can use the render function in a dedicated component:
{{-- resources/views/components/storyblok/richtext.blade.php --}}
@props(['content'])
{!! \App\Services\StoryblokRichtext::render($content) !!}
Then, in the components where you have the richtext field, you can call the richtext component in this way:
@if(!empty($blok['text']))
<div class="prose prose-lg max-w-none">
<x-storyblok.richtext :content="$blok['text']" />
</div>
@endif
💡 Hint: once you understand the dynamics of rendering the richtext editor with PHP, you can evaluate using this package in your project: https://github.com/storyblok/php-tiptap-extension
With this setup, you get:
| Aspect | React/Vue | Laravel + Blade |
|---|---|---|
| Initial page load | Requires hydration | Instant render |
| Bundle size | 50-200KB+ | ~8KB (idiomorph + bridge) |
| SEO | Requires SSR setup | Built-in |
| Deployment | Node.js required | Standard PHP hosting |
| Learning curve | Framework-specific | Familiar Blade syntax |
| Real-time preview | Native support | Works great with bridge |
You don't need React or Vue to build a first-class Storyblok integration. Laravel's Blade templating, combined with a lightweight JavaScript bridge and smart DOM diffing, delivers an excellent visual editing experience.
The key insights:
Give it a try. Your content editors will love the real-time preview, and you'll love the simplicity of staying in the Laravel ecosystem.
Resources:
Have questions? Drop a comment below or find me on Twitter/X.
aiEvery developer tool follows the same pattern: parse flags, run logic, print output. git commit -m...
dockerAPT repositories are just HTTP file servers, doesn't seem like something that should require a custom piece of software.
opensourceWe wanted every employee in the company to use OpenClaw — not just engineers. Product managers...
azurefabricLeveraging Gemini CLI and the underlying Gemini LLM to build Model Context Protocol (MCP) AI...
googlecloudplatformLeveraging Gemini CLI and the underlying Gemini LLM to build Model Context Protocol (MCP) AI...
iacLeveraging Gemini CLI and the underlying Gemini LLM to build Model Context Protocol (MCP) AI...
Workflows from the Neura Market marketplace related to this Grok resource