Implementing a Custom Laravel Blade Precompiler for Volt and Livewire

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 GitHub

The work in this post was inspired by the following Twitter post:

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-volt
2 :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.

#Getting Started: Setting up Our Blade Precompiler

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(): void
14 {
15 //
16 }
17 
18 /**
19 * Bootstrap any application services.
20 */
21 public function boot(): void
22 {
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 @livewireStyles
10 </head>
11 <body class="antialiased">
12 
13 <v-volt
14 :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 @livewireScripts
23 </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

#Parsing Custom Blade Components

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@endif
12 
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: true
10 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: true
16 hasWhitespaceToRight: true
17 isDirty: false
18 // ...
19 sourceContent: "@sectionMissing('navigation')"
20 arguments: Stillat\BladeParser\Nodes\ArgumentGroupNode { }
21 isClosingDirective: false
22 directiveNamePosition: Stillat\BladeParser\Nodes\Position { }
23 isOpenedBy: null
24 isClosedBy: Stillat\BladeParser\Nodes\DirectiveNode {
25 index: 5
26 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: true
33 conditionRequiresClose: true
34 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 @endif
47 """
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): string
10 {
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-volt
13 :count="10"
14 @increment="fn() => $this->count++"
15 >
16 """
17 parent: null
18 isStructure: false
19 structure: null
20 childNodes: array:3 []
21 // ...
22 innerContent: """
23 volt
24 :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: null
38 parameters: array:2 []
39 parameterCount: 2
40 innerDocumentContent: """
41 
42 <button wire:click="increment">Increment</button>
43 
44 <p>Count: {{ $count }}</p>
45 
46 """
47 outerDocumentContent: """
48 <v-volt
49 :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.

#Starting our Compiler Implementation

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:

  1. Detect and parse our custom components. We achieved this in the previous section by leveraging custom parser component prefixes.
  2. Retrieve the inner document and parameters of our components. The Blade parser library provides us with this information out of the box.
  3. Iterate our components and emit the appropriate class-based Volt components. We will also need to save these somewhere to be loaded later.
  4. Replace our custom <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 Compiler
10{
11 protected function isVoltComponent(mixed $node): bool
12 {
13 return $node instanceof ComponentNode &&
14 $node->componentPrefix == 'v' &&
15 $node->name == 'volt';
16 }
17 
18 /**
19 * @param AbstractNode[] $nodes
20 */
21 protected function compileVolt(array $nodes): string
22 {
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): string
51 {
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 @livewireStyles
10 </head>
11 <body class="antialiased">
12 
13 
14 
15 @livewireScripts
16 </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.

#The Component Compiler

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.

#Updating the Volt Service Provider

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(): void
14 {
15 //
16 }
17 
18 /**
19 * Bootstrap services.
20 */
21 public function boot(): void
22 {
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.

#Saving our Components

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 Compiler
11{
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): bool
25 {
26 return $node instanceof ComponentNode &&
27 $node->componentPrefix == 'v' &&
28 $node->name == 'volt';
29 }
30 
31 /**
32 * @param AbstractNode[] $nodes
33 */
34 public function compileVolt(array $nodes): string
35 {
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): string
68 {
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:

#Wrapping Up

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!

Some absolutely amazing
people

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.