DEV Community

Cover image for Laravel & Storyblok: Enabling the Real-Time Visual Editor
Roberto B.
Roberto B.

Posted on

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.

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     │  │  │
│   │  └─────────────┘ └─────────────┘ └─────────────────────┘  │  │
│   │                                                           │  │
│   └───────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Install the Storyblok PHP SDK (for Content Delivery API):

composer require storyblok/php-content-api-client
Enter fullscreen mode Exit fullscreen mode

Add your Storyblok credentials to .env:

STORYBLOK_ACCESS_TOKEN=your_preview_token_here
STORYBLOK_VERSION=draft
Enter fullscreen mode Exit fullscreen mode

In config/serivces.php file add the Storyblok configuration:

    'storyblok' => [
        'access_token' => env('STORYBLOK_ACCESS_TOKEN'),
        'version' => env('STORYBLOK_VERSION', 'published'),
    ],
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/**"],
        },
    },
});

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now configure your Storyblok space's Preview URL to:

https://127.0.0.1:5173/story/
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Why Idiomorph?

The naive approach would be:

currentMain.innerHTML = newMain.innerHTML
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

You can use the render function in a dedicated component:

{{-- resources/views/components/storyblok/richtext.blade.php --}}
@props(['content'])

{!! \App\Services\StoryblokRichtext::render($content) !!}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)