Histoire with Laravel Blade

I’ve started working on a greenfield Laravel project and want to have a component/design system in place from the outset.

My front-end stack will most likely be Stimulus and/or Vue 3 for interactivity, and CubeCSS methodology using UnoCSS and SASS with TailwindCSS as my utility layer. I haven’t got into Inertia or Livewire, so this will be classic rest API plus frontend JS. As for CubeCSS - I’m very much in the utility last camp - I find TailwindCSS on its own to be ugly and unmaintainable. Maybe I’m doing it wrong, but I really don’t understand what problem utility first solves, even in a component-centric build. At the very least, I don’t think the trade-offs are worth it.

Since this is Laravel there will be Blade templates and components. A quick search of solutions brought me straight to Area 17’s Blast. I really dig their Twill CMS for Laravel, in fact I’ve already opted to use it for this project. Unfortunately though, Blast seems pretty much broken for my setup at time of writing, - I installed it but never managed to complete an initial build - Webpack just hung forever with a [webpack-dev-middleware] wait until bundle finished message. I considered trying to debug this, but in the end I wasn’t too sure I really want to use Storybook in any case, I find all that React/JSX syntax a bit clunky for my tastes.

So I moved onto Histoire. Histoire is the Vue & Vite ecosystem’s answer to Storybook - its documentation is a little sparse currently, but it’s very easy to set up for straight up Vue 3 projects. Its syntax is also a lot simpler than Storybook’s, and generally it just works. However I needed to figure out a way to make it support my specific frontend setup.

Initially this will mainly be for presenting styling decisions to the client, and I haven’t really settled on front end js framework(s), so I’m not too fussed about interactivity quite yet. I can tackle e.g. Stimulus support further down the line - for now it’s just about importing Blade components, plus more generally displaying style and design tokens - colours, spacing and typography etc.

I decided to use Histoire’s Vue plugin for the time being, even though these aren’t going to be Vue components, and write a wrapper Vue component to handle the rendering.

I installed the dependencies in my project like this:

pnpm i histoire @histoire/plugin-vue vue @vitejs/plugin-vue -D

Then configured vite and histoire to use their respective vue plugins:

// vite.config.js
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
        vue(),
    ],
})


// histoire.config.js
import { defineConfig } from 'histoire'
import { HstVue } from '@histoire/plugin-vue'

export default defineConfig({
  plugins: [
    HstVue(),
  ],
})

Then I set about putting together a quick and dirty POC for Blade components as follows:

  1. Set up a StoryComponent controller which looks for a component by name and renders it as html
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Blade;

class StoryComponentController extends Controller
{
  public function __invoke(Request $request)
  {
    $component = $request->component;
    $file = base_path('resources/views/components/' . $component . '.blade.php');
    if (file_exists($file)) {
      return Blade::render('<x-' . $component . ' />');
    }

    return abort(404);
  }
}
  1. Set up a “story” route, which takes a component parameter. Make it only work on local and staging.
<?php

use App\Http\Controllers\StoryComponentController;
use Illuminate\Support\Facades\Route;

if (app()->environment(['local', 'staging'])) {
  Route::get('/story/{component}', StoryComponentController::class)->name('story.show');
}
  1. Make sure CORS is set up permissively for this route in config/cors.php so we don’t get errors later.
// config/cors.php
    ...
    'paths' => ['api/*', 'sanctum/csrf-cookie', 'story/*'],
	...
  1. Create a StoryComponent Vue component which takes a component name as a prop and then fetches the html for it via the above route.
<template>
  <div v-html="html"></div>
</template>
<script setup>
import { ref } from 'vue'

const props = defineProps(['component'])
// load html from endpoint
const html = ref('')

async function init() {
  const res = await fetch('https://my-app.test/story/' + props.component)
  html.value = await res.text()
}
init()
</script>
  1. Set up an example blade component
<div>Foo bar baz</div>
  1. Use the new Vue component to import the blade component into a story.
<template>
  <Story title="Foo">
    <StoryComponent component="foo" />
  </Story>
</template>
<script setup>
import StoryComponent from '../resources/histoire/StoryComponent.vue'
</script>

Running pnpm story:dev and this all works a treat.

There’s plenty of things to look at solving later on, for example:

  • As already mentioned, Stimulus and maybe other non-Vue JS interactivity. I’m assuming we might need to inject relevant js such as the compiled stimulus app into the html within the StoryComponent controller. (Update 2024-01-05: This is actually pretty straightforward. Just add the relevant entry point(s) to the global setup)
  • Passing in parameters/props to Blade components. This might mean a POST route, or we could encode some JSON into a query param into the existing GET route. Either way the controller then passes this into the render call.
  • Outputting the underlying html source in the histoire server page, rather than the current wrapper component markup. Not sure how this would work, would need to dig into the histoire code - I assume this might mean writing a proper Histoire plugin for Blade instead of wrapping in Vue. (Update 2024-01-05: There is a quick and dirty way to achieve this, which is by manually adding the source to each Story. Would be nice to automate this though.)
  • Not loading component html async so that the Histoire app isn’t dependent on the Laravel app/api after build. This might also be solved by writing a dedicated plugin and abandoning the Vue wrapper.
  • And probably other stuff I haven’t thought of.

But this gives me a decent basis to build out from.