miko/laravel-latte
Brings the Latte templating engine to Laravel, letting you use Latte's intuitive syntax, automatic escaping, and template inheritance alongside Blade for safer, cleaner views.
miko/laravel-latte wires the Latte templating engine into Laravel's container and config system. It is a thin adapter, not a framework — treat it as "Laravel gets a Latte\Engine singleton and a config file," and you'll be productive within an hour.
First five minutes:
composer require miko/laravel-latte
php artisan vendor:publish --provider="Miko\LaravelLatte\LatteServiceProvider"
config/latte.php), (b) how the engine is exposed — facade, view engine, or container-bound Latte\Engine, and (c) where the compiled-template cache lives.bootstrap/providers.php / config/app.php — if the provider isn't auto-discovered, register it manually. This is the most common reason the package appears "broken."// config/latte.php (names may differ slightly — match the published file)
'cache' => storage_path('framework/latte'),
'debug' => env('APP_DEBUG'),
resources/views/welcome.latte:{* resources/views/welcome.latte *}
<h1 n:block="title">Hello, {$name}</h1>
{if $users}
<ul>
<li n:foreach="$users as $user">{$user->name}</li>
</ul>
{else}
<p>No users yet.</p>
{/if}
view('welcome', [...]) (if it installs a view-engine driver keyed on .latte) or a direct engine call from a controller or the Latte facade:Route::get('/', fn () => view('welcome', ['name' => 'Taylor', 'users' => []]));
First real use case: a marketing/landing page or an admin dashboard where you want Latte's n:attributes, structural inheritance, and context-aware escaping — not a full app rewrite. Keep mailables, error pages, and package views on Blade; introduce Latte page-by-page.
Wrap rendering behind a single helper so you can swap implementations later (see gotchas):
final class LatteRenderer
{
public function __construct(private \Latte\Engine $engine) {}
public function render(string $view, array $params = []): string
{
return $this->engine->renderToString(
view_path($view . '.latte'), // or resource_path("views/{$view}.latte")
$params
);
}
}
Then from controllers you return response($this->renderer->render('dashboard.index', $data)). If the package already installs a view() driver, use view('dashboard.index', $data) directly — less code, but you're coupled to the package's driver.
This is the pattern you'll use daily. Every Laravel convenience Blade gives you needs a small Latte extension: route(), asset(), csrf_field(), @auth/@guest checks, old().
// app/Latte/LaravelFunctionsExtension.php
final class LaravelFunctionsExtension extends \Latte\Extension
{
public function getFunctions(): array
{
return [
'route' => fn (string $name, array $params = []) => route($name, $params),
'asset' => fn (string $path) => asset($path),
'old' => fn (string $key, $default = null) => old($key, $default),
'auth' => fn () => auth()->check(),
'can' => fn (string $ability, $subject = null) => auth()->user()?->can($ability, $subject) ?? false,
];
}
public function getFilters(): array
{
return [
'markdown' => fn (string $s) => Str::markdown($s),
'money' => fn (float $n) => number_format($n, 2),
];
}
}
Register it once and — this is the payoff — every template gets Laravel semantics without a Blade bridge.
// AppServiceProvider::boot()
app(\Latte\Engine::class)->addExtension(new \App\Latte\LaravelFunctionsExtension());
Do not try to mimic @extends/@yield. Latte's native inheritance is cleaner:
{* resources/views/layout.latte *}
<!doctype html>
<html>
<head><title>{block title}App{/block}</title></head>
<body>
{include nav}
<main>{block content}{/block}</main>
</body>
</html>
{* resources/views/users/index.latte *}
{layout 'layout.latte'}
{block title}Users{/block}
{block content}
<a href="{route('users.create')}">New user</a>
<table>
<tr n:foreach="$users as $user">
<td>{$user->name}</td>
<td><a href="{route('users.edit', ['user' => $user])}">Edit</a></td>
</tr>
</table>
{/block}
n:attributes (n:if, n:foreach, n:href) compile to plain PHP and compose well with plain {if} — mix them freely.
There is no @csrf tag. Add one to your extension set:
'csrf' => fn () => new \Latte\Runtime\Html(
'<input type="hidden" name="_token" value="' . csrf_token() . '">'
),
Then <form method="post">{csrf()|noescape}</form> — or, since your extension returns Html, just {csrf()}. Returning Latte\Runtime\Html from extension functions is the correct way to mark something as pre-escaped.
Run both engines side-by-side. A pragmatic split:
| Blade | Latte |
|---|---|
resources/views/mail/** |
New application pages |
errors/*.blade.php |
Admin dashboards |
vendor/* publishes |
Long-lived marketing pages |
Route by view path, not by feature — a developer should never guess which engine a template uses.
Latte's biggest test win: templates compile to plain PHP, so renderToString is fast and assertable.
it('renders user list', function () {
$engine = app(\Latte\Engine::class);
$engine->setTempDirectory(storage_path('framework/testing/latte'));
$html = $engine->renderToString(resource_path('views/users/index.latte'), [
'users' => collect([(object) ['name' => 'Ada']]),
]);
expect($html)->toContain('Ada');
});
Clear the testing temp directory in setUp() so a stale compiled template doesn't mask a test failure.
Latte compiles templates to PHP files and only recompiles when the source mtime changes. That means:
rm -rf storage/framework/latte/*.debug mode (which disables caching via $engine->setAutoRefresh(true)) explicitly in local, and never rely on file mtimes in CI.Latte\CompileException rather than a permission error.Make this a pre-flight step in your local setup script:
if (app()->environment('local')) {
app(\Latte\Engine::class)->setAutoRefresh(true);
}
route(), asset(), csrf_field(), old(), @auth, pagination links, @vite — none of it exists out of the box. Budget a half-day to write one LaravelFunctionsExtension before you start converting pages, otherwise every template becomes a scavenger hunt.
Latte escapes based on surrounding HTML, JS, or CSS context. This is stronger than Blade. The rules:
|noescape — use it only for \Latte\Runtime\Html values you control.{$user->bio|noescape} on user input is an XSS hole; if you're storing HTML, render it with |noescape after sanitizing, not before.{$value} inside a <script> block is JS-escaped, not HTML-escaped. Don't |noescape there "to make JSON work" — use {$data|json} or |dataStream.{ is a control character. Inline <script> and <style> blocks containing object literals or CSS rules will be parsed as Latte tags. Fixes, in order of preference:
{literal}...{/literal} around the block.The engine is typically a singleton. If you add extensions in both a service provider and a middleware, or twice in boot(), you'll get duplicate-function CompileExceptions. Centralize registration in a single LatteServiceProvider of your own.
Compile-time errors arrive as Latte\CompileException with file, line, and the offending source snippet — genuinely good. Runtime errors, though, are plain PHP exceptions thrown from a compiled file under storage/framework/latte/, so stack traces point at generated code. When debugging:
whoops/Ignition plus opening the compiled file instead.With 4 stars and zero dependents, this is a single-maintainer integration (license MIT, recent release, but a small blast radius). Mitigations that cost nothing:
miko/laravel-latte to ^X.Y in composer.json and commit the lock file.LatteRenderer (see above) so a future swap to raw Latte\Engine or back to Blade is a one-file change, not a find-and-replace.If the package's config is limiting you, the real extension surface is Latte itself, not the package:
$engine->addExtension() — functions, filters, tags, providers.$engine->setLoader(new \Latte\Loaders\FileLoader($baseDir)) — control template resolution; useful for namespaced views.$engine->setSandboxMode() — mandatory if any template source is user-editable.Latte\Extension::getTags() — the escape hatch when you need something like {vite ...} without a function call.How can I help you explore Laravel packages today?