Laravel & Storyblok: Enabling the Real-Time Visual Editor —…
    Neura MarketNeura Market/Grok
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityDeepSeekDeepSeek
    CoPilotCoPilotStable DiffusionStable DiffusionMidjourneyMidjourney
    View All Directories
    OverviewRulesPromptsMCPsAgentsGamesBlogVideosGuidesCoursesCommunityTrending
    GrokBlogLaravel & Storyblok: Enabling the Real-Time Visual Editor
    Back to Blog
    Laravel & Storyblok: Enabling the Real-Time Visual Editor
    laravel

    Laravel & Storyblok: Enabling the Real-Time Visual Editor

    Roberto B. February 5, 2026
    0 views

    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">

    Why Laravel + Storyblok?

    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:

    1. Fetch JSON from an API
    2. Render HTML
    3. Execute JavaScript in the browser

    Laravel excels at all three. Plus, you get:

    • Server-side rendering out of the box (great for SEO and GEO)
    • Blade's elegant templating syntax
    • No hydration issues (a common pain point with SSR frameworks)
    • Simpler deployment compared to Node.js applications (but this is my opinion based on my personal experience, probably if you are a devop engineer with Node skill, you will think differently)

    But here is my opinion. Ready?

    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.

    The Architecture

    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:

    1. 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.

    2. 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.

    Step 1: Setting up the Laravel project

    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'),
        ],
    

    Step 2: Enable HTTPS for local development

    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:

    1. Use mkcert 127.0.0.1 to create a proper certificate and key for enabling SSL
    2. Vite serves your frontend at https://127.0.0.1:5173
    3. The proxy forwards all non-Vite requests to Laravel running at http://127.0.0.1:8000
    4. You get HTTPS for the Visual Editor + hot module replacement for development

    Start 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.

    Step 3: Create the Storyblok Service Provider

    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
        {
            //
        }
    }
    
    

    Step 4: The Story Controller

    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.

    Step 5: Dynamic component rendering

    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.

    Step 6: The Storyblok Bridge (where the magic happens)

    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
    
    

    Why Idiomorph?

    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:

    • Only updates elements that actually changed
    • Preserves focus on form inputs
    • Keeps CSS animations running
    • Doesn't reload unchanged images

    The result? Buttery smooth real-time preview.

    Step 7: A responsive image component

    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:

    • Generates a srcset with multiple resolutions
    • Respects Storyblok's focal point for smart cropping
    • Applies quality optimization
    • Uses lazy loading by default
    • Supports fetchpriority="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

    Step 8: Rich Text Rendering

    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

    The Result

    With this setup, you get:

    1. Real-time visual editing - Changes appear instantly as editors type
    2. No flickering - Idiomorph ensures smooth updates
    3. Click-to-edit - Click any component to edit it in Storyblok
    4. SEO-friendly - Server-rendered HTML, no client-side hydration
    5. Fast page loads - No JavaScript framework overhead
    6. Optimized images - Responsive images with automatic WebP conversion

    An opinionated comparison to React/Vue Implementations

    AspectReact/VueLaravel + Blade
    Initial page loadRequires hydrationInstant render
    Bundle size50-200KB+~8KB (idiomorph + bridge)
    SEORequires SSR setupBuilt-in
    DeploymentNode.js requiredStandard PHP hosting
    Learning curveFramework-specificFamiliar Blade syntax
    Real-time previewNative supportWorks great with bridge

    Conclusion

    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:

    1. The Storyblok Bridge is just JavaScript: it works in any browser, regardless of your backend
    2. Server-rendered HTML is fine: just POST the story JSON and return HTML
    3. Idiomorph eliminates flickering: smart diffing makes updates feel native
    4. Blade components map naturally: Storyblok's component model fits perfectly with Blade

    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:

    • Storyblok PHP SDK
    • Idiomorph
    • Storyblok Image Service
    • Storyblok Bridge Documentation

    Have questions? Drop a comment below or find me on Twitter/X.

    Tags

    laravelstoryblokheadlesscmsphp

    Comments

    More Blog

    View all
    Skills Are the New CLIai

    Skills Are the New CLI

    Every developer tool follows the same pattern: parse flags, run logic, print output. git commit -m...

    H
    Helder Burato Berto
    Duct tape enough services together and you can cache APT packagesdocker

    Duct tape enough services together and you can cache APT packages

    APT repositories are just HTTP file servers, doesn't seem like something that should require a custom piece of software.

    D
    Devin H
    How We Use AWS CDK to Deploy OpenClaw for Enterprise Teams — API Key Management Without the Chaosopensource

    How We Use AWS CDK to Deploy OpenClaw for Enterprise Teams — API Key Management Without the Chaos

    We wanted every employee in the company to use OpenClaw — not just engineers. Product managers...

    C
    C.K.Sun
    MCP Development with Python, and Azure Fabricazurefabric

    MCP Development with Python, and Azure Fabric

    Leveraging Gemini CLI and the underlying Gemini LLM to build Model Context Protocol (MCP) AI...

    X
    xbill
    MCP Development with Gemini CLI, Python, and Azure Functionsgooglecloudplatform

    MCP Development with Gemini CLI, Python, and Azure Functions

    Leveraging Gemini CLI and the underlying Gemini LLM to build Model Context Protocol (MCP) AI...

    X
    xbill
    MCP Development with Python, and the Azure Container Instanceiac

    MCP Development with Python, and the Azure Container Instance

    Leveraging Gemini CLI and the underlying Gemini LLM to build Model Context Protocol (MCP) AI...

    X
    xbill

    Stay up to date

    Get the latest Grok prompts, rules, and resources delivered to your inbox weekly.

    Neura Market LogoNeura Market

    Discover the best AI prompts, plugins, and resources for Grok and more.

    Content Types

    • Rules
    • Prompts
    • MCPs
    • Agents
    • Guides

    Platforms

    • ChatGPT Directory
    • Claude Directory
    • Gemini Directory
    • Cursor Directory
    • Grok Directory
    • Perplexity Directory
    • DeepSeek Directory
    • CoPilot Directory
    • Stable Diffusion Directory
    • Midjourney Directory
    • All Directories

    Resources

    • Blog
    • Documentation
    • Help Center
    • Marketplace

    Legal

    • Privacy Policy
    • Terms of Service

    © 2026 Neura Market. All rights reserved.

    |

    Not affiliated with any AI platform vendors.

    Ready-made automations for this

    Workflows from the Neura Market marketplace related to this Grok resource

    • Lead Qualifier System for Effective CRM Automationn8n · $19.99 · Related topic
    • Automated Storyblok Import for Efficient Content Managementn8n · $16.44 · Related topic
    • Automate Story Publishing to Storyblokn8n · $6.99 · Related topic
    • Bulk Publish Storyblok Stories Starting with 'Release'n8n · $5.99 · Related topic
    Browse all workflows