Laravel & Storyblok: Enabling the Real-Time Visual Editor

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.

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


namespace AppProviders;

use IlluminateSupportServiceProvider;
use StoryblokApiStoriesApi;
use StoryblokApiStoryblokClient;

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


namespace AppHttpControllers;

use IlluminateHttpRequest;
use StoryblokApiStoriesApi;
use StoryblokApiRequestStoryRequest;

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 foundspan>
    div>
@endif

Now creating components is straightforward. Here’s an example hero section:

{{-- resources/views/components/storyblok/hero-section.blade.php --}}
@props(['blok'])

<section {!! AppServicesStoryblokEditable::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/idiomorph@0.7.4/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 .= "https://dev.to/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 .= "https://dev.to/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


namespace AppServices;

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

{$content}

"
, 'heading' => "{$node['attrs']['level']}>{$content}{$node['attrs']['level']}>", 'bullet_list' => "
    {$content}
"
, 'ordered_list' => "
    {$content}
"
, 'list_item' => "
  • {$content}
  • "
    , 'blockquote' => "
    {$content}
    "
    , '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' => "{$text}"
    , 'italic' => "{$text}"
    , 'link' => ""{$mark['attrs']['href']}">{$text}", default => $text, }; } return $text; } }

    You can use the render function in a dedicated component:

    {{-- resources/views/components/storyblok/richtext.blade.php --}}
    @props(['content'])
    
    {!! AppServicesStoryblokRichtext::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']))
                
    @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

    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

    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:

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

    Total
    0
    Shares
    Leave a Reply

    Your email address will not be published. Required fields are marked *

    Previous Post

    Replace Turbo confirm with native dialog

    Related Posts