wilycode / template
Compiled PHP template engine with layouts, sections, components and extensions.
Requires
- php: ^8.4
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-15 22:50:29 UTC
README
Lightweight compiled PHP template engine using .tpl files, with layouts, components, extensions and source-aware caching.
WilyCode PHP Ecosystem
This library is part of WilyCode, a collection of independent PHP components. Each package has its own repository, Composer installation and releases.
Requirements
- PHP 8.4 or newer.
- No mandatory runtime dependencies other than PHP.
TemplateEngine owns rendering state, layouts, sections, stacks, components and cache files. Template-language parsing and PHP generation are delegated through TemplateCompilerInterface. The default TemplateCompiler is a small pipeline of focused compiler passes, while TemplateEngine remains the rendering entry point.
Installation
composer require wilycode/template
Configuration
use WilyCode\Template\FileLocator; use WilyCode\Template\TemplateEngine; use WilyCode\Template\TemplateLoader; $locator = new FileLocator([ '' => __DIR__ . '/template', ]); $loader = new TemplateLoader($locator); $engine = new TemplateEngine( loader: $loader, cachePath: __DIR__ . '/var/cache/template', autoReload: true, ); echo $engine->render('article.list', [ 'articles' => $articles, ]);
The logical name article.list resolves to:
template/article/list.tpl
.tpl formatting
Use 4-space indentation. Closing directives align with their opening directives, and block contents are indented by another 4 spaces.
Every nested block adds another 4 spaces. @else and @elseif align with their matching @if.
@section('content')
@if($articles !== [])
<ul>
@foreach($articles as $article)
<li>{{ $article['title'] }}</li>
@endforeach
</ul>
@else
<p>No articles.</p>
@endif
@endsection
Output
{{ $title }}
{!! $trustedHtml !!}
{{ ... }} escapes HTML. {!! ... !!} is only for trusted or previously sanitized HTML.
For normal output, use {{ $value }} directly. Do not write <?= $__engine->e(...) ?> manually and do not add (string) only for rendering. Data should have the correct type before it reaches the view.
Comments
{# This comment is removed during compilation. #}
{#
Multiline
comment.
#}
HTML comments remain in the rendered document:
<!-- Visible in page source. -->
Layouts and sections
template/layout/admin.tpl:
<!doctype html> <html lang="en"> <body> <main> @yield('content') </main> </body> </html>
template/article/list.tpl:
@extends('layout.admin')
@section('content')
<h1>{{ $title }}</h1>
@endsection
Conditional section:
@hasSection('page_actions')
<div class="d-flex gap-2">
@yield('page_actions')
</div>
@endhasSection
Include
@include('article.filters')
@include('article.filters', [
'filters' => $filters,
])
An include inherits the local variable scope. Data passed as the second argument applies only inside that partial.
Variables and data scope
Use @set for local template values and @share for values that a subsequently rendered layout or partial needs to read. They are not interchangeable.
| How a value is provided | Available in the current template? | Available to a later @include? |
Available to a parent layout via @extends? |
|---|---|---|---|
@set('name', $value) |
Yes, after @set |
Yes, through the include's inherited scope | No |
@share('name', $value) |
Not automatically | Yes, unless the include inherits a value of the same name from its caller | Yes, unless explicit render data overrides it |
$engine->render('view', ['name' => $value]) |
Yes | Yes | Yes |
Local values with @set
@set('heading', 'Latest articles')
<h1>{{ $heading }}</h1>
@include('article.summary')
article/summary.tpl can also use {{ $heading }}: @include inherits variables from the point at which it is called. You can override an inherited value for that partial:
@include('article.summary', ['heading' => 'Featured articles'])
@set assigns a PHP variable inside the currently executing template. A layout named by @extends runs in a separate variable scope, so it does not receive that local assignment automatically.
Passing a value to a layout with @share
template/article/show.tpl:
@extends('layout.main')
@share('pageTitle', 'Article details')
@set('heading', 'Latest articles')
@section('content')
<h1>{{ $heading }}</h1>
@include('article.summary')
@endsection
template/layout/main.tpl:
<!doctype html> <html lang="en"> <head> <title>{{ $pageTitle ?? 'Untitled' }}</title> </head> <body> @yield('content') </body> </html>
The layout receives pageTitle through @share; the child template and its subsequent include receive heading through @set. Changing the child to @set('pageTitle', 'Article details') would not change the title in the parent layout.
Important details:
@sharechanges the engine's shared state; it does not create or update a local PHP variable in the template where it appears. Do not assume{{ $pageTitle }}becomes available later in that same file solely because you called@share.- Values passed explicitly to
render('view', ['pageTitle' => 'Explicit title'])take precedence over shared values in the parent layout. Prefer render data for normal page inputs. - Shared values persist on the same
TemplateEngineinstance across separaterender()calls. They are not automatically cleared at the end of a render. Avoid sharing request-specific or sensitive values on an engine reused between requests; use per-request instances or pass page-specific values throughrender()instead.
For globally provided helpers or data, PHP code can call $engine->share('name', $value) before rendering. This uses the same shared state as @share.
Conditions and loops
@if($user !== null) <p>{{ $user['name'] }}</p> @elseif($guestName !== '') <p>{{ $guestName }}</p> @else <p>Guest</p> @endif
@foreach($users as $user) <span>{{ $user['name'] }}</span> @endforeach
Stacks
@push('scripts')
<script src="/assets/article.js"></script>
@endpush
@stack('scripts')
Components
Definition in template/component/ui.tpl:
@component button($label, $type = 'button') <button type="{{ $type }}" class="btn btn-primary"> {{ $label }} </button> @endcomponent @component card($title = null) <section class="card"> @if($title !== null) <header class="card-header"> {{ $title }} </header> @endif <div class="card-body"> {!! $slot !!} </div> </section> @endcomponent
Usage:
@use('component.ui', 'ui')
@ui.button('Save', type: 'submit')
@ui.card(title: 'Article')
<p>Card content.</p>
@endui.card
Extensions
Extensions implement TemplateExtensionInterface and register functions, filters,
shared values or reserved directives. They are separate Composer packages rather
than hard-coded features of this engine.
use WilyCode\Template\TemplateEngine; use WilyCode\Template\TemplateExtensionInterface; $extension = new class implements TemplateExtensionInterface { public function register(TemplateEngine $engine): void { $engine->addFilter('shout', static fn (string $value): string => strtoupper($value)); } }; $engine->addExtension($extension); // Or register several extensions at once: // $engine->addExtensions([$firstExtension, $secondExtension]);
A registered filter can be used as {{ $title | shout }}. The core engine does
not depend on the router, asset handling or translation packages.
Recommended WilyCode extensions
Keep the template engine lightweight and add the features your application needs through dedicated WilyCode packages:
| Extension | Purpose |
|---|---|
| Template Assets | Asset URL helpers for templates. |
| Template String | Reusable string helpers and filters for template output. |
| Template Translation | Translation support for localized templates. |
Install the extensions you need with Composer:
composer require wilycode/template-assets composer require wilycode/template-string composer require wilycode/template-translation
These extensions are separate packages. The core engine does not require any of them; use the extension APIs described above to register integrations in your application.
Functions and filters
$engine->addFunction('route', $routeGenerator); $engine->addFilter('upper', static fn (mixed $value): string => mb_strtoupper((string) $value));
<a href="@route('article.list')"> {{ 'articles' | upper }} </a>
Filters can be chained:
{{ $title | trim | upper }}
Verbatim
@verbatim
<script>
const example = '{{ this stays literal }}';
</script>
@endverbatim
Inside @verbatim, the compiler does not interpret directives or interpolations.
Main template context
During render('article.form'), the engine exposes:
$engine->getCurrentTemplateName(); // article.form
The context remains article.form while rendering:
- a layout through
@extends, - a partial through
@include, - a component through
@useand@alias.component.
After the main render() finishes, the method returns null. Extensions can therefore bind behavior to the page view without depending on the router.
Compiler architecture
The rendering engine depends on TemplateCompilerInterface, not on the concrete compiler:
use WilyCode\Template\TemplateCompiler; use WilyCode\Template\TemplateEngine; $compiler = new TemplateCompiler(); $engine = new TemplateEngine( loader: $loader, cachePath: $cachePath, autoReload: true, compiler: $compiler, );
The default compiler delegates syntax handling to small passes:
TemplateCompiler
├── CommentCompiler
├── ComponentCompiler
├── StateCompiler
├── LayoutCompiler
├── IncludeCompiler
├── ControlFlowCompiler
├── custom CompilerPassInterface implementations
├── FunctionCompiler
└── OutputCompiler
This keeps the parsing responsibilities separate and allows application-specific syntax without subclassing TemplateEngine or editing the built-in compiler.
A custom pass implements CompilerPassInterface:
use WilyCode\Template\Compiler\CompilerContext; use WilyCode\Template\Compiler\CompilerPassInterface; use WilyCode\Template\TemplateCompiler; final class BadgeCompiler implements CompilerPassInterface { public function compile(string $template, CompilerContext $context): string { return str_replace( '@badge($title)', '<span class="badge">{{ $title }}</span>', $template, ); } } $compiler = new TemplateCompiler([ new BadgeCompiler(), ]);
Additional passes run after the built-in structural directives and before registered functions and output interpolation. A pass can use CompilerContext::compileNested() when it introduces nested template source that should go through the same compiler pipeline.
For a completely different compiler implementation, implement TemplateCompilerInterface and inject it through the TemplateEngine constructor.
Cache
$engine = new TemplateEngine($loader, $cachePath, true); // development $engine = new TemplateEngine($loader, $cachePath, false); // production
$engine->clearCache();
With autoReload=true, the compiled cache filename includes SHA-256 fingerprints of the template source and compiler configuration (registered function names, reserved directives, and compiler identity). Changing a template, registering a new function, or changing reserved directives automatically selects a fresh compilation even when the source modification time is unchanged. Different engine instances can safely share a cache directory without using each other's incompatible compiled PHP. Custom injected compilers receive an isolated cache namespace for each engine instance. With autoReload=false, source changes require clearCache(), but changes to compiler configuration are still detected.
Compiled PHP files are published through an atomic rename so another request cannot read a partially written file. Cache filenames are immutable; old source revisions remain on disk until you call clearCache(). On production deployments, clear the template cache when updating templates to reclaim space and ensure source changes are picked up when auto-reload is disabled.
Layout inheritance and component defaults
A layout can extend another layout. The engine evaluates successive parents until it reaches the outermost layout, retaining named sections and shared variables. Intermediate markup is passed to the outer layout as the content section unless the intermediate layout explicitly defines its own content section. Cyclic @extends references raise RuntimeException instead of looping indefinitely. Layout directive arguments can contain nested parentheses.
Untyped component parameters can use expressions in their default values, including nested function calls such as @component badge($label = strtoupper('hello')). These defaults are evaluated only when the argument is omitted; explicitly passing null does not trigger the default. Typed component parameters continue to support ordinary PHP constant-expression defaults.
Rendering failures
Rendering restores the caller's output-buffer level even when an include, section, component or layout throws. Partial output from the failed render is discarded, and the original exception is propagated. The root template context is cleared so the engine can be used for the next render.
Child views and parent layouts are evaluated in separate variable scopes.
A parent's variables are rebuilt from the current shared state and explicit
render data, so @share can replace a shared default set before the render.
Explicit render data retains precedence over shared values; @set remains local
to its view and does not overwrite the parent's variables.
Development and testing
composer validate --strict
composer install
composer test
GitHub Actions runs PHPUnit on PHP 8.4 for pushes and pull requests. Tests and other development files are kept in Git but excluded from Composer archives.
Contributing
Issues and pull requests are welcome. See CONTRIBUTING.md and the issue tracker.
License
MIT © 2026 Simon Piter. See LICENSE.