Skip to content

Instantly share code, notes, and snippets.

@wsydney76
Last active May 10, 2026 10:02
Show Gist options
  • Select an option

  • Save wsydney76/a4fe7b4782849020f8b57f9bad505761 to your computer and use it in GitHub Desktop.

Select an option

Save wsydney76/a4fe7b4782849020f8b57f9bad505761 to your computer and use it in GitHub Desktop.
Short evaluation: Using Blade/Livewire/Flux in Craft 6

Using Blade and Livewire/FLux in Craft 6 alpha

Task

In the context of a long-term project, considering moving a complex Laravel application to Craft 6, benefitting from stuff like multi-site, draft workflows for parts of content (the public facing website), while other content will continue to live in custom database tables.

The project has a significant amount of existing blade templates and relies heavily on Livewire/FLux for interactivity. The goal is to evaluate the feasibility of this migration and identify any potential challenges or benefits.

This is just a first look, just evaluating whether this path is still valid.

Currently in Craft 5, using a private plugin.

Livewire full page component in Craft 6

Defining a route:

// In routes/web.php
Route::livewire('{site}/search', 'pages::search')->name('search');

Livewire component (a very simple search page):

Uses Flux, the first party UI library for Livewire.

<?php

use CraftCms\Cms\Entry\Elements\Entry;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithPagination;

new #[Title('Search')] class extends Component {
    use WithPagination;

    #[Url(history: true)]
    #[Validate('not_regex:/ or /', message: 'The operator "or" must be uppercase.')]
    public string $query = '';

    public function mount()
    {
        $this->validate();
    }

    #[Computed]
    public function results()
    {
        return Entry::find()
            ->section('*')
            ->when($this->query, fn ($q) => $q->search($this->query))
            ->paginate();
    }

    public function updatedQuery(): void
    {
        $this->resetPage();
    }
};
?>

<div class="space-y-4">
    <flux:input
        label="Enter search criteria"
        wire:model.live.debounce.300ms="query"
        clearable
        autofocus
    />

    @if ($this->results->count())
        <ul>
            @foreach ($this->results as $entry)
                <li>{{ $entry->link }}</li>
            @endforeach
        </ul>

        {{ $this->results->links() }}
    @else
        <p>No results.</p>
    @endif
</div>

The layout file:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />

        <title>{{ $title ?? config('app.name') }}</title>
        @vite(['resources/css/app.css', 'resources/js/app.js'])
        @fluxAppearance
    </head>
    <body class="mx-auto max-w-6xl bg-sky-50 p-8">
        <nav class="flex justify-between">
            <a href="/en" class="text-xl font-bold">Craft 6 Experiments</a>
            <div><a href="{{ route('search', ['site' => 'en']) }}">Search</a></div>
        </nav>

        <flux:card class="mt-8">
            <h1 class="mb-6 text-3xl font-bold">{{ $title ?? 'Demo' }}</h1>

            {{ $slot }}
        </flux:card>

        @fluxScripts
    </body>
</html>

Extended version, displaying details via flux:modal:

A bit of workaround is needed to display the entry details in a modal, as Craft elements don't seem to be serializable (in contrast to Eloquent models), and therefore cannot be directly used as reactive properties in Livewire components.

<?php

use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Route\MatchedElement;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithPagination;
use Flux\Flux;

new #[Title('Search')] class extends Component {
    use WithPagination;

    #[Url(history: true)]
    #[Validate('not_regex:/ or /', message: 'The operator "or" must be uppercase.')]
    public string $query = '';

    public array $selectedEntry = [];

    public function mount()
    {
        $this->validate();
    }

    #[Computed]
    public function results()
    {
        return Entry::find()
            ->section('*')
            ->when($this->query, fn ($q) => $q->search($this->query))
            ->paginate();
    }

    public function updatedQuery(): void
    {
        $this->resetPage();
    }

    public function showEntry($id): void
    {
        $entry = Entry::findOne($id);
        // TODO: Investigate: While an eloquent model can be directly used as property, this doesn't work with the matched element. Why?
        // Suspect: Craft elements do not support serialization?
        // So for now, just passing the needed data as an array, which works fine.
        if ($entry) {
            $this->selectedEntry = [
                'id' => $entry->id,
                'title' => $entry->title,
                'url' => $entry->url,
                'image' => $entry->featuredImage->one()?->getUrl(['width' => 400, 'height' => 150]),
            ];
            $this->modal('entry-detail')->show();
        }
    }
};
?>

<div class="space-y-4">
    <flux:input
        label="Enter search criteria"
        wire:model.live.debounce.300ms="query"
        clearable
        autofocus
    />

    @if ($this->results->count())
        <ul>
            @foreach ($this->results as $entry)
                <li>
                    <flux:button
                        variant="ghost"
                        size="sm"
                        wire:click="showEntry({{ $entry->id }})"
                    >
                        {{ $entry->title }}
                    </flux:button>
                </li>
            @endforeach
        </ul>

        {{ $this->results->links() }}
    @else
        <p>No results.</p>
    @endif

    <flux:modal name="entry-detail" class="space-y-4">
        <flux:heading size="lg">{{ $selectedEntry['title'] ?? '' }}</flux:heading>
        @if (! empty($selectedEntry['image']))
            <img
                src="{{ $selectedEntry['image'] }}"
                alt="{{ $selectedEntry['title'] }}"
                class="h-auto w-full rounded"
            />
        @endif

        @if (! empty($selectedEntry['url']))
            <a href="{{ $selectedEntry['url'] }}" class="text-blue-600 hover:underline">
                {{ $selectedEntry['url'] }}
            </a>
        @endif
    </flux:modal>
</div>

Conclusion

This is mostly pure Laravel world, the only Craft-specific part is the entry query in the results computed property.

The entry query allows using methods known from Eloquent, such as when and paginate, which makes it feel very familiar to Laravel developers. The integration of Livewire's pagination with Craft's entry querying is smooth, allowing for efficient handling of search results.

This mostly works as expected, and the Livewire component is fully functional within Craft 6. The integration of Blade templates and Livewire/FLux components appears to be seamless.

Might require workarounds using Craft elements in Livewire components as reactive property, as they don't seem to be serializable, but this is not a major issue.

Blade for Craft entries

In order to render a Craft entry with a Blade template, define its template in the sections site settings with a blade: prefix.

Entry URI format: article/{slug}, Template: blade:_entries.article.index

If you want to call a controller action instead, define the template with an action: prefix, e.g. action:articles/show, and create a controller with a show method that accepts the entry as parameter.

Entry URI format: article/{slug}, Template: action:article/show

In a service provider or plugin, listen to the SetRoute event, check the template defined, and set the route accordingly.

// In a service providers boot method

use CraftCms\Cms\Element\Events\SetRoute;
...
Event::listen(SetRoute::class, function (SetRoute $event) {
    $element = $event->element;

    // Provisionally only top-level entries
    if (!($element instanceof Entry) || !$element->section) {
        return;
    }

    $template = $element->section->getSiteSettings()[$element->siteId]->template ?? null;

    if (!$template) {
        return;
    }

    // If the template is prefixed with "blade:", render with a Blade view.
    // e.g. "blade:_entries.article.index" => view "_entries.article.index.blade.php"
    if (str_starts_with($template, 'blade:')) {
        $bladeView = substr($template, strlen('blade:'));

        $event->handled = true;
        $event->route = [
            'templates/render',
            [
                'template' => $bladeView,
                'variables' => [
                    'entry' => $element,
                ],
            ],
        ];
    }
    
    if (str_starts_with($template, 'action:')) {
        $action = substr($template, strlen('action:'));

        if ($action === '') {
            return;
        }

        $event->handled = true;
        $event->route = [$action, []];
    }
});

Example of a controller:

<?php

namespace App\Http\Controllers;

use CraftCms\Cms\Entry\Elements\Entry;
use CraftCms\Cms\Route\MatchedElement;
use Illuminate\Routing\Controller;
use Illuminate\View\View;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class ArticleController extends Controller
{
    /**
     * Display a single article entry.
     *
     * The entry is resolved by Craft's element routing and stored in MatchedElement
     * before this action is called. Section site template setting: action:article/show
     */
    public function show(): View
    {
        $entry = MatchedElement::get();

        if (!$entry instanceof Entry) {
            throw new NotFoundHttpException();
        }

        return view('_entries.article.index', [
            'entry' => $entry,
            'suggestedEntry' => Entry::find()
                ->section('article')
                ->inRandomOrder()
                ->id(['not', $entry->id])
                ->one(),
        ]);
    }
}

The entry template:

@props([
'entry',
'suggestedEntry' => null,
])

<x-layouts::app :$entry>
    <x-featured-image :$entry />

    <x-blocks :blocks="$entry->contentBuilder->collect()" />

    @if($suggestedEntry)
        <div class="mt-8">
            <flux:heading size="lg">You might also like</flux:heading>
    
            <a href="{{ $suggestedEntry->url }}" class="mt-4 block text-xl font-bold">
                {{ $suggestedEntry->title }}
            </a>
        </div>
    @endif
</x-layouts::app>

From the blocks component, using dynamic components to render each block type with a dedicated blade template, e.g. blocks.text for text blocks, blocks.image for image blocks etc.:

@foreach ($blocks as $block)
    <x-dynamic-component :component="'blocks.' . $block->type->handle" :block="$block" />
@endforeach

Worth noting that Craft's rendering of matrix fields via twig templates also works: {{ $entry->contentBuilder->render() }}.

Custom Directives

Custom Blade directives can be defined as needed in a service provider or plugin.

Blade::directive('diffForHumans', function (string $expression): string {
    return "<?php echo \Carbon\Carbon::instance($expression)->diffForHumans(); ?>";
});

Usage:

<span class="text-sm text-gray-400">
    @diffForHumans($entry->created_at)
</span>

View Composers

View composers are used to automatically bind and share data with specific views every time those views are rendered.

They reduce the need to manually pass data to views from controllers, and help keep controllers clean and focused on handling requests and business logic.

They can be defined as needed in a service provider or plugin, either inline or as a separate view composer class.

use Illuminate\Support\Facades\View as ViewFacade;
use Illuminate\View\View;
...
ViewFacade::composer('components.whats-new', function (View $view) {
    $view->with(
        'latestEntries',
        Entry::find()->section('article')->latest('postDate')->limit(5)->get(),
    );
});

Now a latestEntries variable is available in the components.whats-new view.

Twig extensions for Blade and Livewire

Craft massively extends Twig with custom variables, tags, functions, filters and tests, which are currently not available in Blade.

Maybe these will be added in the future, maybe not, but probably not in early stages. Meanwhile, missing pieces could be added via custom code (see plugin), or replaced by native Blade functionality.

Some Craft Twig features can be used via @inject in Blade, e.g. the Markdown parser:

@inject('md', 'CraftCms\Cms\Markdown\Markdown')

<div>
    {!! $md->parse($block->text) !!}
</div>

In PHP 8.5, the new pipe operator can also be useful to emulate Twig's filter chaining.

    {!! $block->text |> new CraftCms\Cms\Markdown\Markdown()->parse(...) |> [HtmlSanitizers::class, 'sanitize'] !!}

See CraftCms\Cms\Twig\Extensions for Craft's implementation of Twig extensions, which can be used as reference for implementing missing features in Blade.

Variables like $craft, $currentSite etc. seem to be available in Blade.

Conclusion

This works, and allows rendering entries with Blade templates, while still using Twig for other parts of the website. The controller action approach also allows for more complex logic when rendering an entry, if needed.

Custom directives and view composers work as expected. Other extensions should also work, not tested yet, but no obvious reason why they shouldn't.

Event handling needs to be polished, e.g. for nested entries, and to avoid conflicts with other plugins that might listen to the same event, but overall this seems to be a viable approach.

Rendering Blade and Livewire from Twig

Experimental:

Added renderBlade and livewire macros to the craft variable, which allows rendering a Blade template from Twig.

CraftVariable::macro('renderBlade', function (string $view, array $data = []) {
    return Template::raw(view($view, $data)->render());
});

CraftVariable::macro('livewire', function (array $data = []) {
    return Template::raw(view('twig.livewire', $data)->render());
});

A twig layout file:

<!DOCTYPE html>
<html lang="{{ currentSite.language | replace({'_': '-'}) }}">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <title>{{ title ?? siteName }}</title>

    {{ craft.renderBlade('twig.head') }}
</head>
<body class="mx-auto max-w-6xl bg-sky-50 p-8">
<nav class="flex justify-between">
    <a href="/en" class="text-xl font-bold">Craft 6 Experiments</a>
    <div><a href="{{ siteUrl('search', {site: 'en'}) }}">Search</a></div>
</nav>

<div class="mt-8 rounded-lg border border-zinc-200 bg-white p-6 shadow-xs dark:border-white/10 dark:bg-zinc-700">
    <h1 class="mb-6 text-3xl font-bold">{{ title ?? 'Demo' }}</h1>

    {% block content %}{% endblock %}
</div>

{{ craft.renderBlade('twig.footer') }}
</body>
</html>

The twig.head blade file, loading resources:

@vite(['resources/css/app.css', 'resources/js/app.js'])
@fluxAppearance

The twig.footer blade file, loading the Livewire/Flux scripts:

@fluxScripts

The twig.livewire blade file, rendering a Livewire component:

@props([
    'component' => null,
    'data' => [],
])

@livewire($component, $data)

A twig search page, rendering the Livewire search component:

{% extends "layouts/main.twig" %}

{% set title = 'Search' %}

{% block content %}
    {{ craft.livewire({
        component: 'pages::search'
    }) }}
{% endblock %}

Conclusion

This works fine at the first glance, but needs further testing.

Rendering Twig from Blade

Experimental:

Added a @renderTwig directive to render a Twig template from Blade:

Blade::directive('renderTwig', function (string $expression): string {
    return "<?php echo \\CraftCms\\Cms\\Support\\Template::raw(app(\\CraftCms\\Cms\\Twig\\TemplateRenderer::class)->renderTemplate($expression)); ?>";
});

Usage:

@renderTwig('test.twig', ['name' => 'Hanna'])

The twig template:

<p>Hello {{ name }}!</p>

Conclusion

This works.

Filament integration

A lot of custom database tables are maintained for the project, which are currently managed via Filament.

While Filament can be installed without problems, a user cannot log in to the Filament admin panel.

Illuminate\Auth\EloquentUserProvider::validateCredentials(): Argument #1 ($user) must be of type Illuminate\Contracts\Auth\Authenticatable, App\Models\User given, called in /var/www/html/vendor/filament/filament/src/Auth/Pages/Login.php on line 89

Obviously, Craft's user management is not compatible to Filament's (native Laravel?) user management.

Hopefully, this is on the roadmap for Craft 6.

In the meantime, a separate Laravel application could be maintained for the Filament admin panel, which connects to the same database as Craft, but this is not ideal.

Requires maintaining two separate Eloquent models, in each application, including setting up a separate database connection:

// In the Laravel application for Filament
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Subscription extends Model
{
    protected $connection = 'craft6';
    protected $table = 'subscriptions';

    protected $guarded = [];
}

Also means that the Craft application can use its own model for querying, but cannot listen to model events.

<flux:heading size="xl" class="mt-8">Newest Subscriptions</flux:heading>

<ul class="mt-4">
    @foreach (Subscription::latest()->limit(5)->get() as $subscription)
        <li>
            {{ $subscription->name }}
            <span class="text-sm text-gray-400">
                ({{ Carbon::instance($subscription->created_at)->diffForHumans() }})
            </span>
        </li>
    @endforeach
</ul>

Conclusion

This is a significant drawback, as it prevents seamless integration of Filament for managing custom database tables within the Craft application.

Inertia

This is just a quick test on the 'see-if-it-works' level, not a full evaluation of Inertia in Craft 6. Inertia can be installed without problems, and a test page can be rendered with an Inertia component.

Needs a lot of initial configuration, (npm packages, vite/typescript config, app.js, layout view etc.) but once set up, it works as expected. See Laravel's Vue starter kit for reference.

The route definition:

Route::get('/inertia/simple-search', [SimpleSearchController::class, 'search'])->name(
    'inertia.simple-search',
);

The controller:

<?php

namespace App\Http\Controllers\Inertia;

use App\Http\Controllers\Controller;
use CraftCms\Cms\Entry\Elements\Entry;
use Illuminate\Http\Request;
use Inertia\Inertia;

class SimpleSearchController extends Controller
{
    public function search(Request $request)
    {
        $query = (string) $request->string('query');

        $entries = Entry::find()
            ->when($query !== '', fn($q) => $q->search($query))
            ->get()
            ->map(
                fn(Entry $s) => [
                    'id' => $s->id,
                    'title' => $s->title,
                    'url' => $s->url,
                ],
            );

        return Inertia::render('Demos/SimpleSearch', [
            'query' => $query,
            'entries' => $entries,
        ]);
    }
}

The Vue component, using TypeScript and the Composition API:

<script setup lang="ts">
import { ref, watch } from 'vue';
import { Head, router } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import debounce from 'lodash/debounce.js';

type Entry = {
    id: number | string;
    title: string;
    url: string;
};

type SimpleSearchProps = {
    query?: string;
    entries?: Entry[];
};

const props = withDefaults(defineProps<SimpleSearchProps>(), {
    query: '',
    entries: () => [],
});

const localQuery = ref(props.query);

watch(
    localQuery,
    debounce(function (value: string) {
        router.get(
            window.location.pathname,
            { query: value },
            {
                preserveState: true,
                preserveScroll: true,
                replace: true,
                only: ['query', 'entries'],
            }
        );
    }, 300)
);
</script>

<template>
    <Head title="Simple Search" />

    <Heading text="Simple Search" />

    <div class="space-y-4">
        <div class="space-y-2">
            <label class="block text-sm font-medium">Search</label>
            <input v-model="localQuery" type="text" class="input w-full" autofocus />
        </div>

        <ul>
            <li v-for="entry in props.entries ?? []" :key="entry.id">
                <a :href="entry.url">{{ entry.title }}</a>
            </li>

            <li v-if="(props.entries?.length ?? 0) === 0">No results.</li>
        </ul>
    </div>
</template>

Conclusion

For applications that already use Inertia, or for developers who prefer it, this could be a viable option for building interactive pages in Craft 6.

However, this requires a different developer skill set (no, messing around with AI doesn't count as 'skill').

Given that Livewire/FLux is already available,seems to integrate well and is well established, it will be more efficient to stick with Livewire/FLux for interactivity, unless there are specific features of Inertia that are needed.

Summary

Overall, the initial evaluation of using Blade and Livewire/FLux in Craft 6 looks promising. The integration of Livewire components within Craft's routing system appears to be smooth, and rendering Blade templates from Twig is feasible with some custom macros.

However, the lack of compatibility between Craft's user management and Laravel's authentication system is a significant hurdle that needs to be addressed for a seamless experience.

Further testing and development should be done when a public beta is available.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment