eftec/bladeone
BladeOne is a standalone, lightweight implementation of Laravel's Blade template engine for plain PHP. It offers layouts, sections, includes, directives, caching, and extensibility without requiring Laravel, so you can use familiar Blade syntax in any PHP project.
BladeOne is a single-class Blade implementation with zero dependencies — you get @extends, @section, @include, @if, @foreach, escaping, and custom directives without installing Laravel.
Install
composer require eftec/bladeone
# or drop the single BladeOne.php file in and require it
Minimum viable setup
require __DIR__ . '/vendor/autoload.php';
use eftec\bladeone\BladeOne;
$views = __DIR__ . '/views'; // .blade.php files
$cache = __DIR__ . '/cache'; // compiled PHP — writable, outside the docroot
$blade = new BladeOne($views, $cache, BladeOne::MODE_AUTO);
echo $blade->run('pages.home', ['title' => 'Home', 'user' => $user]);
views/pages/home.blade.php:
@extends('layouts.app')
@section('content')
<h1>{{ $title }}</h1>
@include('partials.nav', ['user' => $user])
@endsection
Where to look first
MODE_* constants — they decide whether compilation happens per request (MODE_AUTO), never (MODE_FAST), or always (MODE_DEBUG). This is the single biggest operational lever.run(), runString(), share(), with(), compile() in the source file — that's the whole public surface you'll use daily.compileString / compile* methods to see exactly which directives exist before assuming a Laravel directive is supported.First real use case: replace a hand-rolled include-and-echo layout in a legacy or micro-framework app. Point $views at your existing templates folder, move your header/footer into layouts/app.blade.php, and convert pages one at a time — old and new templates can coexist.
Layouts and partials with dot notation
{{-- layouts/app.blade.php --}}
<!doctype html>
<html>
<head><title>@yield('title', 'App')</title></head>
<body>
@include('partials.header')
@yield('content')
@include('partials.footer')
</body>
</html>
@extends('layouts.app') maps to layouts/app.blade.php. Keep one level of layout + one level of partials; deep inheritance chains are painful to debug without Laravel's error reporting.
Central view service instead of scattering new BladeOne
final class View
{
private static BladeOne $blade;
public static function boot(string $env): void
{
self::$blade = new BladeOne(
__DIR__ . '/views',
__DIR__ . '/cache',
$env === 'prod' ? BladeOne::MODE_FAST : BladeOne::MODE_AUTO
);
self::$blade->setBaseUrl('https://example.test');
self::$blade->share('appName', 'Acme');
self::$blade->share('csrf', $_SESSION['csrf'] ?? '');
}
public static function render(string $view, array $data = []): string
{
return self::$blade->run($view, $data);
}
}
share() is for request-lifetime globals (user, CSRF, app name); pass per-page data through run() so templates stay explicit and testable.
Custom directives for your domain
// compile-time directive: emits PHP
$blade->directive('money', function ($expr) {
return "<?php echo number_format((float)($expr), 2); ?>";
});
// runtime directive: evaluated immediately, simpler, slower
$blade->directiveRT('now', fn () => date('c'));
// custom conditional: @admin ... @endadmin
$blade->if('admin', fn () => ($_SESSION['role'] ?? '') === 'admin');
This is how you backfill Laravel directives you miss:
$blade->directive('csrf', fn () => '<input type="hidden" name="_token" value="'
. htmlspecialchars($_SESSION['csrf'] ?? '', ENT_QUOTES) . '">');
Emails and one-off strings
runString() compiles and renders an inline template, which is ideal for notification bodies where you don't want a file per variant. For anything reused, create a real view — string templates get no editor support and are easy to break with a stray quote.
Asset URLs and links
$blade->setBaseUrl('https://cdn.example.test');
$blade->setAssetDict(['logo' => '/img/logo.svg']);
Then {{ $blade->asset('logo') }} inside templates, or register an @asset directive. There is no global asset() helper like Laravel's — that trips people up constantly.
Deploy/build pattern (the one that matters)
// bin/compile-views.php — run in CI or on deploy
$blade = new BladeOne($views, $cache, BladeOne::MODE_DEBUG);
foreach (glob($views . '/**/*.blade.php') as $file) { /* derive view name */ }
$blade->compile('pages.home'); // compile each known view
Compile on deploy, then run production in MODE_FAST. This removes per-request filemtime() stat calls and eliminates the "half-written compiled file" race between concurrent requests on a cold cache.
Long-running workers (Swoole, RoadRunner, queue daemons)
Compile with MODE_DEBUG/manual compile() and call opcache_invalidate($compiledPath, true) after recompiling, or restart workers on deploy. MODE_AUTO's filemtime check is cheap but opcache will still serve the stale bytecode unless invalidation is enabled.
Extension via traits/subclassing
The ecosystem add-ons follow the same shape: BladeOneCache (fragment caching via @cache), BladeOneLang (@lang/_e), BladeOneHtml (form helpers). Pull in the trait rather than inventing your own caching directive — the compiled-cache key handling is the fiddly part. If you need to change compiled output globally (e.g., add a wrapper to e()), subclass and override compileString, not individual directives.
Compilation mode is the #1 source of "my changes don't show up."
MODE_AUTO recompiles when the template's mtime is newer than the compiled file. If a deploy preserves or back-dates mtimes (archive extraction, rsync with -t, a clock-skewed build box), edits silently never compile. Fix: find cache -name '*.php' -delete on deploy, or force touch on templates.MODE_FAST ignores template mtimes entirely. Never deploy it without purging the compiled directory.MODE_DEBUG (recompile every request) rather than guessing at mtimes.Compiled files are executable PHP. Keep the compiled directory outside the web root. If shared hosting forces it inside, deny access via .htaccess/nginx location rules, and be aware include still works with a non-.php compiled extension if the engine supports configuring one. Anyone who can write a file there gets code execution.
Error line numbers point at the compiled file, not your template. On a syntax or runtime error, open the generated file in the cache directory and read the actual emitted PHP. That is also the fastest way to understand what a directive compiled into — inspect before you post a bug report. Cache the mapping from compiled line to template line mentally, or add a /* template: x line: y */ marker by overriding the compiler if you debug often.
Laravel-isms are not there. No @csrf, @auth, @can, @vite, @error, @dump, old(), route(), config(), csrf_token(), dd(). Templates copy-pasted from a Laravel project will render literal directive text or fatal. Port them deliberately: @csrf → custom directive, @auth → $blade->if('auth', ...), route() → precomputed URL in the data array.
Escaping and {!! !!}. {{ }} compiles to an e()-style htmlentities call (ENT_QUOTES, UTF-8) — good default, but it is not Laravel's e() (no doubleEncode parameter) and it will not protect you inside <script>, <style>, or HTML attributes expecting JSON. @json() may not exist depending on version; use json_encode(..., JSON_HEX_TAG|JSON_HEX_AMP) explicitly. @php ... @endphp blocks bypass escaping entirely — treat them as an escape hatch, not a pattern.
Dynamic view names from user input are an LFI risk. @include($userView) or run($request->get('page')) lets an attacker walk out of the template directory. Whitelist view names against a fixed array or regex before calling run()/include.
Variable scope and reserved names. @include inherits the parent scope in most versions, which makes partials convenient but implicit — pass data explicitly if you care about testability. Avoid variable names that collide with the engine: $this, $blade, and anything the compiler uses internally. A template variable called $loop is fine unless you rely on $loop metadata (index, first, last) — verify that support exists in your pinned version before designing around it, as $loop availability varies by release.
@section/@endsection vs @stop. Both usually work, but be consistent. Content placed outside @section blocks in a child template that uses @extends can produce output in unexpected positions; keep child templates limited to sections and @includes.
BOM and leading whitespace. A UTF-8 BOM before <?php in a template or in your bootstrap file emits bytes before headers and can break header()/session starts. Save templates as UTF-8 without BOM, and never leave a blank line before <?php in included config files.
Compilation is not atomic by default. Two simultaneous cold requests can both write the same compiled file. Precompiling on deploy (or at least warming the cache after clearing it) is the practical fix; if you cannot precompile, accept that the first requests after a purge may be slower and occasionally noisy.
License metadata is "NOASSERTION." Read the LICENSE file in the repository before shipping — don't infer MIT from the README. This matters for distribution and for anything client-facing.
Maintenance reality check. A single-file, low-velocity package means you are close to the source by design. Pin an exact version in composer.json, keep your custom directives and overrides in your own files (not patched into the vendor copy), and write one smoke test that renders a layout + partial + custom directive so a version bump surfaces breakage immediately.
How can I help you explore Laravel packages today?