helsingborg-stad/blade
Blade component library for Laravel providing reusable UI components, view helpers, and templating utilities used across Helsingborg Stad's PHP applications to keep frontend markup consistent.
You already know the syntax — this package is Laravel's Blade without Laravel. Treat it as a templating component you drop into a non-Laravel runtime (WordPress theme, legacy PHP app, CLI/static generator, microservice), not as something to add to a Laravel app.
Minimal setup:
composer require helsingborg-stad/blade
use HelsingborgStad\Blade\Blade;
$blade = new Blade(
__DIR__ . '/views', // where .blade.php files live
__DIR__ . '/cache/views' // where compiled PHP is written
);
echo $blade->render('pages.home', ['name' => 'World']);
Three things to verify before you write a template:
Blade class source. The README is thin; the class is the real documentation. In ~60 lines you'll see exactly what's exposed: render, share, directive, if, composer registration. That's your whole API surface — worth five minutes.illuminate/view version got installed (composer show helsingborg-stad/blade). That determines which Blade features and directives are available to you.First day-one use case: take an existing Blade layout + partial set from a Laravel project, drop it into views/, and render it from a plain PHP entry point. If @extends, @section, @include and {{ }} work — and they will — you've validated the package for your project. Then work through the template replacing Laravel-only helpers (see Gotchas) with directives of your own.
Build one instance, in one place. The compiler and view finder are expensive to construct. Create the Blade instance in a bootstrap file and pass it around (or hold it in a tiny container of your own), rather than new Blade(...) inside a template or a controller method. Per-request instantiation is the most common performance mistake with this package.
// bootstrap.php
$blade = new Blade(__DIR__.'/views', __DIR__.'/cache/views');
$blade->share('siteName', get_bloginfo('name'));
require __DIR__.'/directives.php'; // register once, here
return $blade;
Wrap it in a seam you own. A five-line adapter class (View::render($name, $data)) means the day you swap engines, or upgrade a major version, it's a one-file change instead of a repo-wide find-and-replace. Cheap now, expensive later.
Rebuild the helpers you actually use as directives. The asset(), route(), csrf_field(), @vite family doesn't exist here. Rather than littering templates with raw PHP, register directives once:
$blade->directive('asset', fn ($path) => "<?php echo '/assets/' . trim($path, \"'\\\"\"); ?>");
$blade->directive('wpurl', fn () => "<?php echo home_url(); ?>");
Then @asset('css/app.css') reads exactly like the Laravel template you ported. Keep all directive registrations in a single directives.php — this becomes the file every new developer reads to understand "what Blade words work in this codebase."
Use composers to keep templates free of globals. This is the pattern that pays off daily. Instead of calling wp_query() or get_current_user_id() inside a template, inject the data:
$blade->composer('partials.header', function ($view) {
$view->with('user', wp_get_current_user());
$view->with('menu', get_nav_menu_items('primary'));
});
Composers run per-render on the matching view, so templates stay dumb and testable — you can render them from a CLI script with a stub composer when there's no WordPress in scope.
Share once, pass per-render. share() for values every single template needs (site name, asset base URL, environment). The second argument to render() for anything specific to that render. Mixing the two is how you end up with share('post', $post) called right before every render, which defeats the point.
Custom conditionals replace framework conditionals. @env, @production, @auth aren't there. Register your own:
$blade->if('env', fn ($env) => wp_get_environment_type() === trim($env, "'\""));
$blade->if('debug', fn () => defined('WP_DEBUG') && WP_DEBUG);
Capture output at the boundary. In a WordPress theme, wrap the_content() and shortcode output so it becomes a string passed into the view as data, rather than being echoed inside the template mid-render. Echoing inside a Blade template works but it breaks your ability to test the rendered output as a pure string — which is the testing strategy here (see below).
Snapshot-test rendered output. render() returns a string; that's the ideal seam for tests. No framework assertions available, so:
$out = $blade->render('partials.card', ['title' => 'Hello']);
$this->assertStringContainsString('Hello', $out);
A CI smoke test that renders one real page and asserts on a known fragment is the cheapest insurance against a silent upstream break — add it before rollout, not after.
Cache discipline per environment. Production: compile once, serve many; clear the cache on deploy only. Development: delete compiled files when a template changes, or wire a small mtime check in your bootstrap. There's no view:clear command to lean on.
Missing helpers fail silently or confusingly. @auth, @can, @csrf, route(), old(), @vite aren't provided by the standalone engine. Audit every ported template rather than assuming a @ token is a no-op. Grep for @auth, @can, @csrf, @vite, route(, old( before you call a port done.
No app(), no config(). Anything you'd normally resolve from the container has to be passed in explicitly or wrapped in a directive. This is the single biggest mental adjustment — plan for it structurally rather than reaching for globals.
Escape the cache directory problem up front. Create it in bootstrap and fail loudly if not writable. Deploy scripts that wipe cache/ but don't recreate the directory are a recurring 500. Also confirm across horizontally scaled nodes: either share the compiled path or warm the cache per node.
Custom directives get raw strings. Quote handling, whitespace, and nested parentheses are on you. Test with single quotes, double quotes, and an argument containing a paren before you ship a directive to the team. A directive that breaks on @asset("img/a'b.png") will surface months later.
A {!! !!} audit is not optional. {{ }} escapes; {!! !!} doesn't. Without Laravel's broader conventions as a safety net, be explicit with anyone porting templates that raw output is a deliberate decision, especially for database- or user-sourced content.
Debug with the cache first. When markup is stale or a template change doesn't take effect, the answer is almost always the compiled cache. Delete cache/*.php and re-render before you go hunting further.
Template exceptions lose their Laravel ergonomics. No whoops handler means compiled-template errors can be terse. During development wrap render() in try/catch and log the exception with its file and line — that's the actual template line in the compiled file, which is close enough to find the source.
Watch the illuminate/view constraint. If the same project already pulls other Illuminate components (or a Laravel package), a constraint clash can block an upstream security upgrade. Run composer why illuminate/view and record the pinned range in your dependency notes.
Pin the version deliberately. 3.5.1 is a patch release with a compare-link-only changelog — treat it as a routine fix bump, pin to ^3.5 (or exact, if you want deliberate PRs), and re-render your smoke test. Read the diff if you're upgrading across anything that touches the compiler, view finder, or cache path.
Extension points worth knowing: directive() for new tags, if() for conditionals, composers for view-scoped data, and — if you ever need non-filesystem views — a custom ViewFinder or EngineResolver wired into the underlying illuminate/view. Reach for the Compiler last; that's deep water.
Keep application templates out of any path the package or vendor tree controls. Views live in your repo, owned by you, so an upgrade or a package change can never silently move your templates.
How can I help you explore Laravel packages today?