Some Laravel apps need the same change made to every Blade file at once, like renaming a component or normalising a directive. A sed one-liner replaces every match, including the ones inside strings, comments, and attributes you did not want changed. Forte, written by John Koster, parses .blade.php into a typed syntax tree, lets you query the tree, and renders a rewritten template back out.
Koster also wrote prettier-plugin-blade. Version 3 of that formatter runs on Forte.
Use it when you need to find out what your views contain, apply one edit across hundreds of them, or fail a build when a template breaks one of your team's conventions.
Installation
Forte needs PHP 8.2, the dom extension, and Laravel 10 through 13:
composer require fortephp/forte
The service provider registers itself, so the Forte facade is available immediately.
Parsing
Forte::parse() takes a string, Forte::parseFile() takes a path:
use Forte\Facades\Forte; $doc = Forte::parse('<div class="mt-4">Hello, {{ $name }}!</div>');$doc = Forte::parseFile('resources/views/welcome.blade.php');
Both go through a lexer, which turns the source into tokens, and a tree builder, which assembles the nodes. A Document holds the result.
Given an unclosed tag or a @if with no @endif, the parser records a diagnostic on the document and returns a partial tree. You can query and rewrite that tree like any other. Two broken views in a four-hundred-view app produce two diagnostics, and the other 398 parse normally.
Parse a file and render it back without changes and you get the same bytes, whitespace included.
Querying the Tree
The query methods return lazy collections, so you can filter and map them the way you would any other Laravel collection:
$forms = $doc->queryElements('form');$conditionals = $doc->queryBlockDirectives(['if', 'unless']);$components = $doc->queryComponents(['x-alert', 'livewire:*']);
For a single known node there are direct lookups:
$navigation = $doc->elementById('primary-navigation');$hasHead = $doc->hasElement('head');
isDynamic() tells you whether the value came from a bound attribute, and attributeTokens('class') splits a class list into an array:
$form = $doc->firstElement('form');$method = $form?->attribute('method');$isDynamic = $method?->isDynamic() ?? false;$classes = $form?->attributeTokens('class') ?? [];
Forte builds a DOMDocument from the tree and runs the expression through PHP's DOMXPath, which is what the ext-dom requirement covers. Blade constructs become elements in a forte namespace, so @if blocks are forte:if and echoes are forte:echo:
$divs = $doc->xpath('//div[@class]')->get();$conditionals = $doc->xpath('//forte:if')->get();
Matches come back as Forte nodes rather than DOMElement objects, so you can pass a query result to a rewrite. Finding every <a> inside a <nav> that has no href is one expression instead of a recursive walk.
Rewriting
apply(), rewrite(), and rewriteWith() each return a new Document and leave the original alone, so you can keep both and compare them.
rewriteWith() handles one-off changes with a closure. The callback receives a NodePath rather than the node itself:
use Forte\Rewriting\NodePath; $newDoc = $doc->rewriteWith(function (NodePath $path) { if ($path->isTag('a') && str_starts_with($path->getAttribute('href') ?? '', 'http')) { $path->setAttribute('target', '_blank'); $path->setAttribute('rel', 'noopener noreferrer'); }}); echo $newDoc->render();
A NodePath exposes the parent, siblings, ancestors, and depth of the node it points at, along with getAttribute(), setAttribute(), removeAttribute(), addClass(), renameTag(), replaceWith(), remove(), insertBefore(), and insertAfter(). skipChildren() and stopTraversal() end the traversal early once you have found the node you want.
Forte queues the edits rather than applying them one at a time, so a pass over a large template produces one new document instead of one per edit.
For anything longer than a closure, write a visitor:
use Forte\Rewriting\Visitor;use Forte\Rewriting\NodePath; class NormalizeAlerts extends Visitor{ public function enter(NodePath $path): void { if (! $path->isTag('div') || ! $path->hasAttribute('data-alert')) { return; } $level = $path->getAttribute('data-alert') ?? 'info'; $path->renameTag('x-alert'); $path->setAttribute('type', $level); $path->removeAttribute('data-alert'); }}
enter() runs before the node's children are visited, and leave() runs after. Most passes only need enter(). Use leave() when the change depends on what happened to the children, such as unwrapping an element once its contents have been rewritten.
There is also a RewriteBuilder for the declarative version: select nodes by XPath, then queue the mutations to apply to the matches.
Building New Nodes
Rewrites often need a new node to put in place. Builder makes one:
use Forte\Rewriting\Builders\Builder; Builder::element('div')->class('wrapper')->text('Hello');Builder::directive('if', '($show)');Builder::echo('$name');
Pass the result to replaceWith(), insertBefore(), or insertAfter().
Auditing, Codemods, and CI Checks
Finding Out What Your Views Contain
Before deleting a component, find every view that still renders it:
use Forte\Facades\Forte;use Illuminate\Support\Facades\File; foreach (File::allFiles(resource_path('views')) as $file) { if (! str_ends_with($file->getFilename(), '.blade.php')) { continue; } $uses = Forte::parseFile($file->getPathname()) ->queryComponents(['x-alert']) ->count(); if ($uses > 0) { echo "{$file->getRelativePathname()}: {$uses}\n"; }}
grep -rc 'x-alert' resources/views answers a similar question in one line, and it counts the mentions in comments, in @php strings, and in a class="x-alert-icon" attribute alongside the real ones. The count above is the number of times the component is rendered.
Making the Same Edit in Hundreds of Views
Adding loading="lazy" to every <img> that has no loading attribute is a pass you can run, review as a diff, and re-run after you adjust it:
use Forte\Facades\Forte;use Forte\Rewriting\NodePath;use Illuminate\Support\Facades\File; foreach (File::allFiles(resource_path('views')) as $file) { if (! str_ends_with($file->getFilename(), '.blade.php')) { continue; } $doc = Forte::parseFile($file->getPathname()); $updated = $doc->rewriteWith(function (NodePath $path) { if ($path->isTag('img') && ! $path->hasAttribute('loading')) { $path->setAttribute('loading', 'lazy'); } }); file_put_contents($file->getPathname(), $updated->render());}
An <img> written inside a comment, a string, or a @php block parses as a different node kind, so isTag('img') is false for it. Getting that right with a regex takes more care than the rest of the job.
If the pass was wrong, fix the script, run git restore resources/views, and try again.
Failing the Build on a Broken Convention
A POST form with no @csrf is one expression, so the check fits inside a test:
use Forte\Facades\Forte;use Illuminate\Support\Facades\File; test('every POST form has a CSRF token', function () { $offenders = []; foreach (File::allFiles(resource_path('views')) as $file) { if (! str_ends_with($file->getFilename(), '.blade.php')) { continue; } $missing = Forte::parseFile($file->getPathname()) ->xpath('//form[@method="POST"][not(.//forte:csrf)]') ->count(); if ($missing > 0) { $offenders[] = $file->getRelativePathname(); } } expect($offenders)->toBeEmpty();});
That expression reads left to right: every <form> with method="POST", keeping the ones with no @csrf anywhere inside them. The .// is the "anywhere inside" part, so a form whose @csrf is in a sibling form still counts as an offender.
A @foreach whose first child has no wire:key is also one expression:
$missingKeys = $doc->xpath('//forte:foreach[*[1][not(@*[name()="wire:key"])]]')->count();
//forte:foreach matches every @foreach block. *[1] is the first child element of that block. not(@*[name()="wire:key"]) keeps the blocks whose first child has no wire:key attribute. The @*[name()="..."] form is there because XPath reads the colon in wire:key as a namespace separator.
Both expressions are tests in Forte's own suite. The second one depends on nesting and on which child comes first.
When a Regex Is Enough
For one rename across thirty views, sed or your IDE's structural search plus a careful read of the diff is less work than writing a visitor. The same goes for an edit you make once and never check again.
Forte is worth the setup in these cases:
- The rule depends on structure: "inside a form", "the first child of", "nested in a
@foreach". A grep matches lines, so it cannot express these. - The edit touches so many files that reading the whole diff by hand takes longer than writing the pass.
- The check runs on every commit, where a match inside a comment or a string fails the build for no reason.
Chisel and Reload
Two other packages are built on Forte, and you can use both without writing a visitor. Chisel is prettier-plugin-blade v3, rewritten against the new parser; the project reports it formatting complex real-world templates 140 times faster than the previous version. It needs Node 18 or later:
npm i -D prettier prettier-plugin-blade@^3
Blade formatting has more options than it used to, including Laravel Pint's own Blade support and format-on-save in PhpStorm.
Reload is a Vite plugin that patches Blade changes into the page without a full refresh. Its docs call it experimental, and it falls back to a full reload after max_patches_before_reload incremental patches:
composer require fortephp/reload --dev
It watches resources/views/**/*.blade.php and instruments elements, components, directives, and includes. The refresh option in laravel-vite-plugin already reloads the page when a Blade file changes. Reload patches the DOM instead.
Forte is MIT licensed and currently at v1.1.0. The source is on GitHub, the documentation and an interactive playground are at fortephp.com.