krystal-sf/ux-blocks
UX Blocks for Laravel provides a library of reusable, ready-made UI blocks for your Blade views, helping you assemble polished interfaces faster with consistent styling and minimal markup. Great for dashboards, marketing pages, and admin panels.
Read this first: krystal-sf/ux-blocks is a Symfony UX package — Twig templates, Symfony DI tags, a compiler pass. It has no Laravel service provider, no Blade views, and no Laravel-facing API. composer require krystal-sf/ux-blocks into a Laravel app will at best install dead weight and at worst drag in Symfony/Twig constraints that fight your framework version. The correct day-to-day use of this package in a Laravel team is as a reference implementation: read it, extract the tagged-block pattern, own ~200–400 lines of Blade-native code.
Minimal steps, in order:
git clone https://gitlab.com/krystal-sf/ux-blocks /tmp/ux-blocks
Start at composer.json (PHP/Symfony/Twig constraints — this tells you if it could even co-exist with your app), then src/.grep -rn "addTaggedService\|findTaggedServiceIds\|CompilerPass" src/
grep -rn "interface .*Block" src/
The string passed to findTaggedServiceIds() (or the autoconfigure tag declaration in the DI extension) is the whole contract. Write it down.tests/. With zero docs and zero stars, tests are the only spec.First use case (the one you should actually ship): a BlockRegistry in your Laravel app so a marketing page can be assembled from config/blocks.php instead of hardcoded Blade @includes.
// app/Blocks/BlockRegistry.php
namespace App\Blocks;
use Illuminate\Support\Facades\View;
class BlockRegistry
{
protected array $blocks = [];
public function register(string $tag, array $definition): static
{
if (isset($this->blocks[$tag]) && app()->isProduction()) {
report(new \RuntimeException("Duplicate block tag [$tag]"));
}
$this->blocks[$tag] = $definition + ['view' => null, 'priority' => 0, 'strict' => null];
return $this;
}
public function has(string $tag): bool { return isset($this->blocks[$tag]); }
public function all(): array
{
return collect($this->blocks)->sortByDesc('priority')->all();
}
public function render(string $tag, array $data = []): string
{
$block = $this->blocks[$tag] ?? null;
$strict = $block['strict'] ?? app()->isLocal();
if (! $block || ! $block['view'] || ! View::exists($block['view'])) {
$strict
? throw new \RuntimeException("Block [$tag] is not resolvable.")
: logger()->warning("Block [$tag] missing; rendering nothing.");
return '';
}
return view($block['view'], $data)->render();
}
}
Bind it once, load config in the provider, and expose a Blade component:
// app/Providers/BlockServiceProvider.php
public function register(): void
{
$this->app->singleton(BlockRegistry::class, function () {
$registry = new BlockRegistry;
foreach (config('blocks', []) as $tag => $definition) {
$registry->register($tag, $definition);
}
return $registry;
});
}
{{-- resources/views/components/block.blade.php --}}
@props(['tag', 'data' => []])
{!! app(\App\Blocks\BlockRegistry::class)->render($tag, $data) !!}
Then config/blocks.php maps tags to views, and a page becomes <x-block tag="hero" />. If you cannot get that single block rendering within an hour, stop — that is your assessment result.
Pattern 1 — config-driven tag → view (start here). For marketing/campaign pages the registry is a map, not a class hierarchy.
// config/blocks.php
return [
'hero' => ['view' => 'blocks.hero', 'priority' => 100],
'feature-grid'=> ['view' => 'blocks.feature-grid','priority' => 50],
'cta' => ['view' => 'blocks.cta', 'priority' => 10],
];
Keep block views colocated: resources/views/blocks/hero.blade.php, plus blocks/hero.css.js if you need a scoped script via @vite/@push('scripts'). This replaces the package's "self-contained block bundles template, logic, assets" claim with Vite + Blade partials.
Pattern 2 — class-based blocks (mirror the Symfony interface). Once blocks get logic, port the interface shape directly: one class per block, a tag() and a render(array $data): View|string.
interface BlockContract
{
public function tag(): string;
public function view(): string;
public function data(array $context = []): array; // defaults + validation
}
Register them in the same provider ($registry->register($block->tag(), ['view' => $block->view()])). This keeps the package's extension seam — a compiler-pass-style collect-then-sort — intact, but in Laravel idioms.
Pattern 3 — composition and nesting. Blocks assemble pages, blocks nest blocks. Render order comes from priority (or an explicit weight array passed at render time when the page editor controls sequencing):
@foreach (app(\App\Blocks\BlockRegistry::class)->forPage('home') as $tag)
<x-block :tag="$tag" />
@endforeach
Pattern 4 — cache the resolved list, not the rendered HTML. The registry is static per request/build; memoize all() in the singleton (done above by construction). Cache rendered blocks separately and explicitly, and key on the block tag + input data hash + view cache key, never the tag alone.
Integration tips.
<x-block>) rather than injecting the registry into controllers. The package's Twig function/tag is the analogue; keep the entry point declarative.BlockRendering / BlockRendered events around render() — that is the cheap extension point for analytics, AB tests, and cache warmers, and the one thing worth copying structurally from the original.'raw' => true for CMS-authored HTML), not decided at the call site.strict flag that throws only in local/CI. Silent 500s on a marketing page after a content deploy is the classic failure. Never inherit a throw default from a Symfony dev environment.<!-- block:hero missing --> so the page still ships. Assert both branches in tests.hero produces unmaintainable content bugs because last-wins is invisible. Throw in non-production, log loudly in production, and document the precedence.php artisan view:cache; run view:clear (or a view:cache) in your deploy script. If you cache rendered blocks in Redis, include a build/version stamp in the key or you will serve last release's hero indefinitely.{!! !!} confined to the render() implementation, sanitize with mews/purifier or HTMLPurifier, and gate it behind an explicit per-tag flag. This is the highest-severity failure mode in the whole pattern.src/ before copying it. In your port, sort explicitly (sortByDesc('priority')) and make ties deterministic (tag name as secondary key).register()/extend()), the contract interface, the render events, and the config/blocks.php map. Everything else — Twig tags, DI autoconfiguration, the bundle class — is Symfony machinery you are deliberately not porting.resources/views, view:cache stale, escaping swallowing output, then exception being swallowed by a catch-all in your renderer. Log the resolved view path on every render at debug level.How can I help you explore Laravel packages today?