September 4, 2023 —John Koster
In this post, I thought it'd be fun to look at implementing a custom component compiler for Laravel's Blade templating language.
Grab the example code on GitHubThe work in this post was inspired by the following Twitter post:
Just imagine… pic.twitter.com/mm1Ovush81
— @chris@any.dev (@inxilpro) August 28, 2023
We will build a custom compiler that transforms the following custom component syntax into a Livewire component, leveraging new features provided by Volt.
1<v-volt2 :count="10"3 @increment="fn() => $this->count++"4>5 <button wire:click="increment">Increment</button>6 7 <p>Count: {{ $count }}</p>8</v-volt>
Our compiler will parse the input document, convert our custom components into a class-based Volt component behind the scenes, and insert the appropriate Livewire directives to mount our dynamically created Livewire component.
Starting with a fresh Laravel application, we need to install two new dependencies. The first will be Volt, which will also add Livewire to our project for us:
1composer require livewire/volt
The second dependency we will use is a Blade parser implementation I wrote a while back; we will use this Blade parsing library to do most of the heavy lifting for us:
1composer require stillat/blade-parser
After installing both packages, we can run the following command to publish Volt's service provider, which we will use later to modify some paths:
1php artisan volt:install
Those will be the only additional dependencies we need for the remainder of this article. With these two now available, let's create a new class at app/Joule/Compiler.php
. I've decided to utilize the Joule
namespace since it fits the theme of Livewire and Volt.
In app/Joule/Compiler.php
:
1<?php 2 3namespace App\Joule; 4 5class Compiler 6{ 7 public function compile(string $input): string 8 { 9 dd($input);10 }11}
Right now, our class just defines a compile method that dumps the input to the screen. Next, we'll register a Blade precompiler to modify a Blade template before compilation. An important fact to keep in mind is that Laravel executes Blade precompilers after it compiles its own <x-
components but before it compiles directives. Because we cannot easily override the compilation of Blade components and to avoid conflicts with existing Blade components, we'll use the <v-
prefix for our custom components.
We now need to register our precompiler for our custom compiler to do anything.
In app/Providers/AppServiceProvider.php
:
1<?php 2 3namespace App\Providers; 4 5use Illuminate\Support\Facades\Blade; 6use Illuminate\Support\ServiceProvider; 7 8class AppServiceProvider extends ServiceProvider 9{10 /**11 * Register any application services.12 */13 public function register(): void14 {15 //16 }17 18 /**19 * Bootstrap any application services.20 */21 public function boot(): void22 {23 Blade::precompiler(function ($str) { 24 return app('App\Joule\Compiler')->compile($str);25 });26 }27}
I have updated my resources/views/welcome.blade.php
to contain the following Blade template:
1<!DOCTYPE html> 2<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> 3 <head> 4 <meta charset="utf-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1"> 6 7 <title>Laravel</title> 8 9 @livewireStyles10 </head>11 <body class="antialiased">12 13 <v-volt14 :count="10"15 @increment="fn() => $this->count++"16 >17 <button wire:click="increment">Increment</button>18 19 <p>Count: {{ $count }}</p>20 </v-volt>21 22 @livewireScripts23 </body>24</html>
If we were to visit our application, we should see the contents of our view dumped on the screen.
Note: If you receive errors like Undefined variable $count
, you may need to clear your applications view cache. This can be done by running the following command:
php artisan view:clear
With our Blade precompiler scaffolding out of the way, we can now start working on parsing our custom Blade components. We will use the Blade parsing library we included earlier since it provides some utility features that make it much easier to parse custom Blade components. We mainly interact with the parsing library through its Document API, which allows us to supply arbitrary string contents, which it then parses as a Blade template. For example, the following code:
1<?php 2 3use Stillat\BladeParser\Document\Document; 4 5$blade = <<<'BLADE' 6 7@sectionMissing('navigation') 8 <div class="pull-right"> 9 @include('default-navigation')10 </div>11@endif12 13BLADE;14 15$doc = Document::fromText($blade)16 ->resolveStructures();17 18dd($doc->getNodes()->all());
This would produce results similar to the following:
1array:7 [ 2 0 => Stillat\BladeParser\Nodes\LiteralNode { } 3 1 => Stillat\BladeParser\Nodes\DirectiveNode { 4 index: 1 5 id: "N2778d352f2bf-c14e-4462-8ade-692d99292a30" 6 position: Stillat\BladeParser\Nodes\Position { } 7 content: "sectionMissing" 8 parent: null 9 isStructure: true10 structure: Stillat\BladeParser\Nodes\Structures\Condition { }11 childNodes: array:3 []12 fragmentPosition: Stillat\BladeParser\Nodes\Fragments\FragmentPosition { }13 previousNode: Stillat\BladeParser\Nodes\LiteralNode { }14 nextNode: Stillat\BladeParser\Nodes\LiteralNode { }15 hasWhitespaceOnLeft: true16 hasWhitespaceToRight: true17 isDirty: false18 // ...19 sourceContent: "@sectionMissing('navigation')"20 arguments: Stillat\BladeParser\Nodes\ArgumentGroupNode { }21 isClosingDirective: false22 directiveNamePosition: Stillat\BladeParser\Nodes\Position { }23 isOpenedBy: null24 isClosedBy: Stillat\BladeParser\Nodes\DirectiveNode { 25 index: 526 id: "N6464b5790d03-a7d2-4db2-8843-f1e569801350"27 position: Stillat\BladeParser\Nodes\Position { }28 content: "endif"29 parent: Stillat\BladeParser\Nodes\DirectiveNode { }30 // ...31 }32 isConditionDirective: true33 conditionRequiresClose: true34 conditionStructureName: "if"35 innerDocumentContent: """36 37 <div class="pull-right">38 @include('default-navigation')39 </div>40 """41 outerDocumentContent: """42 @sectionMissing('navigation')43 <div class="pull-right">44 @include('default-navigation')45 </div>46 @endif47 """48 }49 2 => Stillat\BladeParser\Nodes\LiteralNode { }50 3 => Stillat\BladeParser\Nodes\DirectiveNode { }51 4 => Stillat\BladeParser\Nodes\LiteralNode { }52 5 => Stillat\BladeParser\Nodes\DirectiveNode { }53 6 => Stillat\BladeParser\Nodes\LiteralNode { }54]
When we use the Document::fromText
method, we also chain the resolveStructures method to it. By default, the Blade parser library only parses a document's contents into different nodes without linking them. This separation is mainly for performance reasons. However, when we invoke resolveStructures
, the parser identifies which Blade directives pair together. For instance, in our example, it recognizes that the @sectionMissing
directive pairs with the @endif
directive, despite their different names. In addition to handling these nuances for us, resolving a document's structures will produce component tag pairs for us later.
By default, the Blade parser will only parse standard Blade components that begin with <x-
. However, we can tell the parser we want it to parse components with a different prefix easily by passing an array of custom component tag prefixes when we create our document.
If we were to update our custom compiler to the following:
1<?php 2 3namespace App\Joule; 4 5use Stillat\BladeParser\Document\Document; 6 7class Compiler 8{ 9 public function compile(string $input): string10 {11 $nodes = Document::fromText($input, customComponentTags: ['v'])12 ->resolveStructures()13 ->getNodes()14 ->all();15 16 dd($nodes);17 }18}
We would now get output similar to the following:
1array:13 [ // app/Joule/Compiler.php:16 2 0 => Stillat\BladeParser\Nodes\LiteralNode { } 3 1 => Stillat\BladeParser\Nodes\EchoNode { } 4 2 => Stillat\BladeParser\Nodes\LiteralNode { } 5 3 => Stillat\BladeParser\Nodes\DirectiveNode { } 6 4 => Stillat\BladeParser\Nodes\LiteralNode { } 7 5 => Stillat\BladeParser\Nodes\Components\ComponentNode { 8 index: 5 9 id: "N644474c6cb69-3b7e-4d75-9dad-9d0655f06474"10 position: Stillat\BladeParser\Nodes\Position { }11 content: """12 <v-volt13 :count="10"14 @increment="fn() => $this->count++"15 >16 """17 parent: null18 isStructure: false19 structure: null20 childNodes: array:3 []21 // ...22 innerContent: """23 volt24 :count="10"25 @increment="fn() => $this->count++"26 27 """28 parameterContent: """29 30 :count="10"31 @increment="fn() => $this->count++"32 33 """34 name: "volt"35 tagName: "volt"36 isClosedBy: Stillat\BladeParser\Nodes\Components\ComponentNode { }37 isOpenedBy: null38 parameters: array:2 []39 parameterCount: 240 innerDocumentContent: """41 42 <button wire:click="increment">Increment</button>43 44 <p>Count: {{ $count }}</p>45 46 """47 outerDocumentContent: """48 <v-volt49 :count="10"50 @increment="fn() => $this->count++"51 >52 <button wire:click="increment">Increment</button>53 54 <p>Count: {{ $count }}</p>55 </v-volt>56 """57 }58 6 => Stillat\BladeParser\Nodes\LiteralNode { }59 7 => Stillat\BladeParser\Nodes\EchoNode { }60 8 => Stillat\BladeParser\Nodes\LiteralNode { }61 9 => Stillat\BladeParser\Nodes\Components\ComponentNode { }62 10 => Stillat\BladeParser\Nodes\LiteralNode { }63 11 => Stillat\BladeParser\Nodes\DirectiveNode { }64 12 => Stillat\BladeParser\Nodes\LiteralNode { }
From the output, we can see that the parser is providing us with a lot of information about our custom component; the parser will give us any parameters, inner content, closing pairs, and a wealth of additional information, including any child nodes belonging to the component.
We will start making use of this information in the next section.
For this blog post, our compiler implementation will remain limited in scope and focus on being able to compile the simple counting example from the introduction. Our compiler needs to be able to accomplish the following:
<v-volt />
components in the original document with a @livewire
directive to load them in.We get steps 1 and 2 for free by using the Blade parsing library; let us start working on step 3, which is to iterate our custom Blade components and create a class-based Volt component.
Let's look at how we can reconstruct the Blade template with our custom components removed to get started. Doing this will provide us with a great foundation to continue building on.
In app/Joule/Compiler.php
:
1<?php 2 3namespace App\Joule; 4 5use Stillat\BladeParser\Document\Document; 6use Stillat\BladeParser\Nodes\AbstractNode; 7use Stillat\BladeParser\Nodes\Components\ComponentNode; 8 9class Compiler10{11 protected function isVoltComponent(mixed $node): bool12 {13 return $node instanceof ComponentNode &&14 $node->componentPrefix == 'v' &&15 $node->name == 'volt';16 }17 18 /**19 * @param AbstractNode[] $nodes20 */21 protected function compileVolt(array $nodes): string22 {23 $compiled = '';24 25 $skipTo = null;26 27 foreach ($nodes as $node) {28 if ($skipTo) {29 // Reset the skipTo node if we've reached it.30 if ($node === $skipTo) {31 $skipTo = null;32 }33 34 continue;35 }36 37 if ($this->isVoltComponent($node)) {38 /** @var ComponentNode $node */39 40 // We want to skip over all the component's children.41 $skipTo = $node->isClosedBy;42 } else {43 $compiled .= (string) $node;44 }45 }46 47 return $compiled;48 }49 50 public function compile(string $input): string51 {52 $nodes = Document::fromText($input, customComponentTags: ['v'])53 ->resolveStructures()54 ->getNodes()55 ->all();56 57 $result = $this->compileVolt($nodes);58 59 // We will remove this later.60 dd($result);61 62 return $result;63 }64}
If we now visit our application, we should see the following content dumped on our screen:
1<!DOCTYPE html> 2<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> 3 <head> 4 <meta charset="utf-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1"> 6 7 <title>Laravel</title> 8 9 @livewireStyles10 </head>11 <body class="antialiased">12 13 14 15 @livewireScripts16 </body>17</html>
A lot is going on with the changes to our custom compiler. However, the main thing we are doing is iterating each node returned by the Blade parser. If the node is not one of our custom component nodes, we append the string version of the node to our $compiled
result. The one non-obvious thing we are doing revolves around the $skipTo
variable.
The Blade parser will return all nodes as a flat list. Later, when creating our class-based Volt component, we use the node's inner content within the render
method. We are using the $skipTo
variable to jump over all of the component's children nodes so we do not end up with duplicate output in our final, compiled view, which would lead to unexpected behavior.
At this point, we are ready to begin implementing our actual component compiler. We will create a couple of new classes to help keep things organized.
The first will be a data object containing the details of each compiled class and serves as a starting point to add new behaviors later if needed.
In app/Joule/CompiledComponent.php
:
1<?php 2 3namespace App\Joule; 4 5class CompiledComponent 6{ 7 public function __construct( 8 public string $name, 9 public string $class,10 ) {11 }12}
And now for the component compiler itself.
In app/Joule/ComponentCompiler.php
:
1<?php 2 3namespace App\Joule; 4 5use Illuminate\Support\Str; 6use Stillat\BladeParser\Nodes\Components\ComponentNode; 7use Stillat\BladeParser\Nodes\Components\ParameterNode; 8use Stillat\BladeParser\Nodes\Components\ParameterType; 9 10class ComponentCompiler 11{ 12 private static int $componentCount = 0; 13 14 protected Compiler $voltCompiler; 15 16 public function __construct(Compiler $voltCompiler) 17 { 18 $this->voltCompiler = $voltCompiler; 19 } 20 21 private function getComponentName(): string 22 { 23 return 'joule_'.self::$componentCount++; 24 } 25 26 protected function compilePublicMethod(ParameterNode $param): string 27 { 28 $methodName = Str::after($param->name, '@'); 29 $methodBody = trim($param->value); 30 31 if (Str::startsWith($methodBody, 'fn(')) { 32 $methodBody = trim(Str::after($methodBody, '=>')); 33 } 34 35 $methodBody = Str::finish($methodBody, ';'); 36 37 return strtr(<<<'METHOD' 38 public function %name%() 39 { 40 %body% 41 } 42 43METHOD, 44 [ 45 '%name%' => $methodName, 46 '%body%' => $methodBody, 47 ] 48 ); 49 } 50 51 public function compile(ComponentNode $componentNode): CompiledComponent 52 { 53 $name = $this->getComponentName(); 54 55 $innerContent = $this->voltCompiler->compileVolt($componentNode->childNodes); 56 $publicMethods = []; 57 $publicProperties = []; 58 59 /** @var ParameterNode $param */ 60 foreach ($componentNode->getParameters() as $param) { 61 if ($param->type == ParameterType::Parameter) { 62 if (Str::startsWith($param->name, '@') || Str::startsWith(trim($param->value), 'fn(')) { 63 $publicMethods[] = $this->compilePublicMethod($param); 64 } else { 65 // A parameter's materialized name will have any leading symbols removed. 66 $publicProperties[] = ' public $'.$param->materializedName.' = '.$param->value.';'; 67 } 68 } elseif ($param->type == ParameterType::DynamicVariable) { 69 $publicProperties[] = ' public $'.$param->materializedName.' = '.$param->value.';'; 70 } 71 } 72 73 $classStub = <<<'CLASS' 74<?php 75 76use Livewire\Volt\Component; 77 78new class extends Component 79{ 80%props% 81 82%methods% 83} 84?> 85 86<div> 87{{-- __joule --}} 88%template% 89</div> 90CLASS; 91 92 $class = strtr($classStub, [ 93 '%props%' => implode("\n", $publicProperties), 94 '%methods%' => implode("\n", $publicMethods), 95 '%template%' => $innerContent, 96 ]); 97 98 return new CompiledComponent($name, $class); 99 }100}
Our component compiler looks intimidating but is essentially just a string builder. The component compiler requires a regular Compiler
instance in its constructor; we use this to recursively compile the component's inner content, which will later serve as the template for our class-based Volt component. The heart of the implementation happens between lines 59 and 71:
1// ... 2/** @var ParameterNode $param */ 3foreach ($componentNode->getParameters() as $param) { 4 if ($param->type == ParameterType::Parameter) { 5 if (Str::startsWith($param->name, '@') || Str::startsWith(trim($param->value), 'fn(')) { 6 $publicMethods[] = $this->compilePublicMethod($param); 7 } else { 8 // A parameter's materialized name will have any leading symbols removed. 9 $publicProperties[] = ' public $'.$param->materializedName.' = '.$param->value.';';10 }11 } elseif ($param->type == ParameterType::DynamicVariable) {12 $publicProperties[] = ' public $'.$param->materializedName.' = '.$param->value.';';13 }14}15// ...
We iterate each component's parameters and either create a public method from them or add the parameter details to our $publicProperties
array. The Blade parser will categorize parameters into different types, but we only utilize the Parameter
and DynamicVariable
types. Each parameter also contains a materializedName
property, just the name with any leading symbols removed.
Once we have iterated each of the parameters, we use PHP's strtr
function to handle our replacements for us and then return a new CompiledComponent
instance.
Before continuing, we will make some additions to our applications Volt service provider.
In app/Providers/VoltServiceProvider.php
:
1<?php 2 3namespace App\Providers; 4 5use Illuminate\Support\ServiceProvider; 6use Livewire\Volt\Volt; 7 8class VoltServiceProvider extends ServiceProvider 9{10 /**11 * Register services.12 */13 public function register(): void14 {15 //16 }17 18 /**19 * Bootstrap services.20 */21 public function boot(): void22 {23 $compiledPath = storage_path('framework/views/compiled_inline_livewire'); 24 25 if (! file_exists($compiledPath)) {26 mkdir($compiledPath, 0755, true);27 }28 29 Volt::mount([30 resource_path('views/livewire'),31 resource_path('views/pages'),32 $compiledPath, 33 ]);34 }35}
Our additions will create a new director for us to store our compiled components and add to the array of paths Volt uses to look for its components. We have added our path to the end to help prevent interfering with normal Volt components and behavior.
Now that we have a place to store our compiled components, and Volt knows to look for them, we need to update our primary compiler implementation to utilize the ComponentCompiler
, replace the component in the source with a @livewire
directive, and save the component in our new directory.
In app/Joule/Compiler.php
:
1<?php 2 3namespace App\Joule; 4 5use Illuminate\Support\Str; 6use Stillat\BladeParser\Document\Document; 7use Stillat\BladeParser\Nodes\AbstractNode; 8use Stillat\BladeParser\Nodes\Components\ComponentNode; 9 10class Compiler11{12 protected ComponentCompiler $componentCompiler; 13 14 /**15 * @var CompiledComponent[]16 */17 protected array $components = [];18 19 public function __construct()20 {21 $this->componentCompiler = new ComponentCompiler($this);22 }23 24 protected function isVoltComponent(mixed $node): bool25 {26 return $node instanceof ComponentNode &&27 $node->componentPrefix == 'v' &&28 $node->name == 'volt';29 }30 31 /**32 * @param AbstractNode[] $nodes33 */34 public function compileVolt(array $nodes): string35 {36 $compiled = '';37 38 $skipTo = null;39 40 foreach ($nodes as $node) {41 if ($skipTo) {42 // Reset the skipTo node if we've reached it.43 if ($node === $skipTo) {44 $skipTo = null;45 }46 47 continue;48 }49 50 if ($this->isVoltComponent($node)) {51 $component = $this->componentCompiler->compile($node); 52 53 $compiled .= '@livewire(\''.$component->name.'\')';54 55 $this->components[] = $component;56 57 // We want to skip over all the component's children.58 $skipTo = $node->isClosedBy;59 } else {60 $compiled .= (string) $node;61 }62 }63 64 return $compiled;65 }66 67 public function compile(string $input): string68 {69 // Prevent compiling input that has already been compiled.70 if (Str::contains($input, ['__ENDBLOCK__ ', '{{-- __joule --}}'])) {71 return $input;72 }73 74 $nodes = Document::fromText($input, customComponentTags: ['v'])75 ->resolveStructures()76 ->getNodes()77 ->all();78 79 $result = $this->compileVolt($nodes);80 81 foreach ($this->components as $component) { 82 $path = storage_path('framework/views/compiled_inline_livewire/').$component->name.'.blade.php';83 84 file_put_contents($path, $component->class);85 }86 87 return $result;88 }89}
Whenever we compile a new <v-volt />
, we add it to the compiler's internal $components
array. Before we return our compiled result, we iterate each of those components and store the created class in our temporary storage directory. If we were to dump the final $result
before returning, our output should be similar to the following:
As you can see, our compiler has inserted a @livewire
directive where the <v-volt />
component used to be. By utilizing this approach, we can let Volt do the heavy lifting of rendering and managing our components, and all we need to worry about is outputting a compatible class-based component string.
If we were to visit our application in the browser now, we should get results similar to the following:
We've covered a lot of ground in this post, but there is still much work to do to make it more feature-rich. Examples of things we could add to our compiler:
Support adding attributes to public properties, Add support for computed properties, Accept component constructor dependencies, And a lot more! If you found this blog post interesting, consider sharing it with friends and colleagues!
∎
The following amazing people help support this site and my open source projects ♥️
If you're interesting in supporting my work and want to show up on this list, check out my GitHub Sponsors Profile.