Laravel Folio deep dive

post-thumb

Taylor recently launched the beta version of Laravel Folio YouTube video of Laracon keynote . Currently, the documentation only consists of a readme file, reflecting the simplicity of the package. With its straightforward nature, we can easily guess its inner workings. I’ve decided to dive deeper into this, and in this blog post, we’ll journey together to discover its internal mechanisms.

What is Laravel Folio?

In simple terms, Laravel Folio is a page-based router for your Laravel application. All you need to do is create a blade file. There’s no need to write routes or create controller methods to return the view. It’s as straightforward as that.

How to Use It?

I won’t dive into the details of Folio’s usage here, as it only takes about 5 minutes to install the composer package and run the install command, just like any standard Laravel package. Also, bear in mind that it’s still in beta, meaning there could be significant changes before the v1 release.

However, I can guide you to the LaravelFolioServiceProvider in your app/Providers directory, where you’ll find this code in the boot method after installation:

1public function boot(): void
2{
3    Folio::route(resource_path('views/pages'), middleware: [
4        '*' => [
5            //
6        ],
7    ]);
8}

We’ll dig deeper into what the route method does in a bit. But from an initial glance, it’s clear that it scans all files within view/pages and either establishes a route for each one or a single handler that serves the requested page.

A Closer Look at Folio

Before we dive into Folio’s source code, I think it would be more beneficial to show you how to create a simple version ourselves. Let’s work together to implement a basic replication of Folio.

A Basic Folio Replica

First, let’s create a blade file at resources/views/pages/profile.blade.php. This file will simply contain a ‘Hello World’ string:

1<?php
2    $message = 'Hello World';
3?>
4<div>
5    {{ $message }}
6</div>

Next, we’ll craft a class similar to the Folio one. We’ll name it Pager.

 1namespace App;
 2
 3use Illuminate\Support\Facades\Route;
 4
 5class Pager
 6{
 7    public static function route(string $path): void
 8    {
 9        $files = collect(scandir(resource_path('views/'.$path)))
10            ->skip(3) // ['.', '..', '.gitkeep']
11            ->filter(fn ($file) => str_ends_with($file, '.blade.php'))
12            ->map(fn($file) => str_replace('.blade.php', '', $file));
13
14        $files->each(function ($view) use($path) {
15            Route::get($view, fn () => view($path.'/'.$view));
16        });
17    }
18}

And in AppServiceProvider boot method we can use Pager like

1public function boot(): void
2{
3    Pager::route('pages');
4}

In the route method, it takes the path of the directory within resources/views that contains all the blade files for which we want to generate routes. For each {something}.blade.php, we create a route with URI {something} that returns view('pages/{something}'), much like any regular create method within a Controller. Now, when we navigate to app.test/profile, it will return the ‘Hello World’ string. This forms the basic, simplified implementation of Folio.

Of course, our example doesn’t cater to more complex scenarios, such as nested directories or dynamic pages like views/pages/users/[id].blade.php. In the following sections, we’ll examine how Folio manages these situations.

The Core of Folio

In Folio, everything begins with the FolioServiceProvider located in your project’s config/app.php.

1public function boot(): void
2{
3    Folio::route(resource_path('views/pages'), middleware: [
4        '*' => [
5            //
6        ],
7    ]);
8}

The route method is responsible for scanning the path and implementing any middleware rules we define.

Now, let’s dive into what it contains.

 1public function route(string $path = null, ?string $uri = '/', array $middleware = []): static
 2{
 3    $path = $path ? realpath($path) : config('view.paths')[0].'/pages';
 4
 5    if (! is_dir($path)) {
 6        throw new InvalidArgumentException("The given path [{$path}] is not a directory.");
 7    }
 8
 9    $this->mountPaths[] = $mountPath = new MountPath($path, $uri, $middleware);
10
11    if ($uri === '/') {
12        Route::fallback($this->handler($mountPath))
13            ->name($mountPath->routeName());
14    } else {
15        Route::get(
16            '/'.trim($uri, '/').'/{uri?}',
17            $this->handler($mountPath)
18        )->name($mountPath->routeName())->where('uri', '.*');
19    }
20
21    return $this;
22}

It begins by determining the $path to be used, and it has a default path if one isn’t provided.

Next, it generates a MountPath object and adds it to the mountPaths array. This tells us that we can use the route method as many times as we need if our views are distributed across multiple directories.

After creating the MountPath object, it checks if the URI is targeting the root page of our app. If so, it registers a handler as a route fallback. If not, it defines a route with the targeted path and passes the same handler to resolve all routes for our views.

It’s likely that we’ll use Folio for our root page, so let’s dive straight into the $this->handler call to see what it does.

 1protected function handler(MountPath $mountPath): Closure
 2{
 3    return function (Request $request, string $uri = '/') use ($mountPath) {
 4        return (new RequestHandler(
 5            $mountPath,
 6            $this->renderUsing,
 7            fn (MatchedView $matchedView) => $this->lastMatchedView = $matchedView,
 8        ))($request, $uri);
 9    };
10}

The handler method returns a closure, which is called when Laravel’s Router processes a new request.

Next, let’s investigate the RequestHandler class.

 1class RequestHandler
 2{
 3    /**
 4     * Create a new request handler instance.
 5     */
 6    public function __construct(protected MountPath $mountPath,
 7        protected ?Closure $renderUsing = null,
 8        protected ?Closure $onViewMatch = null)
 9    {
10    }
11
12    /**
13     * Handle the incoming request using Folio.
14     */
15    public function __invoke(Request $request, string $uri): mixed
16    {
17    }
18
19    /**
20     * Get the middleware that should be applied to the matched view.
21     */
22    protected function middleware(MatchedView $matchedView): array
23    {
24    }
25
26    /**
27     * Create a response instance for the given matched view.
28     */
29    protected function toResponse(MatchedView $matchedView): Response
30    {
31    }
32}

Let’s look into the __invoke method

 1public function __invoke(Request $request, string $uri): mixed
 2{
 3    $matchedView = (new Router(
 4        $this->mountPath->path
 5    ))->match($request, $uri) ?? abort(404);
 6
 7    return (new Pipeline(app()))
 8        ->send($request)
 9        ->through($this->middleware($matchedView))
10        ->then(function (Request $request) use ($matchedView) {
11            if ($this->onViewMatch) {
12                ($this->onViewMatch)($matchedView);
13            }
14
15            return $this->renderUsing
16                ? ($this->renderUsing)($request, $matchedView)
17                : $this->toResponse($matchedView);
18        });
19}

The method kicks off by locating a matching view, which refers to a blade file. It then constructs a pipeline to perform the following tasks:

  • Forward the request to the middlewares list
  • Execute the onViewMatch callback if it’s defined
  • Render the view

Now, let’s dive into the $this->middleware method.

 1protected function middleware(MatchedView $matchedView): array
 2{
 3    return Route::resolveMiddleware(
 4        $this->mountPath
 5            ->middleware
 6            ->match($matchedView)
 7            ->prepend('web')
 8            ->merge($matchedView->inlineMiddleware())
 9            ->unique()
10            ->values()
11            ->all()
12    );
13}

Essentially, the method returns an array of middlewares. We should particularly examine the $matchedView->inlineMiddleware() method.

Folio Inline Middleware

Folio allows you to enforce middleware checks directly within your blade file. Here’s an example:

 1<?php
 2    use function Laravel\Folio\middleware;
 3
 4    middleware(['auth']);
 5
 6    $message = 'Hello World';
 7?>
 8<div>
 9    {{ $message }}
10</div>

Take note that we have to place the PHP code within the opening tags <?php //here ?>, and we can’t use the blade directive like:

1@php
2    use function Laravel\Folio\middleware;
3
4    middleware(['auth']);
5@endphp

If you use a directive, the middleware won’t function because the rendering of blade templates takes place much later than the middleware check step. Therefore, with a directive, it would be defined when rendering the blade file, which effectively does nothing.

How Does the Middleware Function Work?

If we examine the middleware function:

1function middleware(Closure|string|array $middleware = []): PageOptions
2{
3    Container::getInstance()->make(InlineMetadataInterceptor::class)->whenListening(
4        fn () => Metadata::instance()->middleware = Metadata::instance()->middleware->merge(Arr::wrap($middleware)),
5    );
6
7    return new PageOptions;
8}

It sets a closure within the InlineMetadataInterceptor whenListening method and passes it a closure that returns an array of middlewares.

Note that Metadata::instance() here is a singleton object. However, it gets cleared after every request handled by Folio. So even with Laravel Octane, you don’t have to worry about this.

InlineMetadataInterceptor

Back to $matchedView->inlineMiddleware(), let’s see what it accomplishes:

1public function inlineMiddleware(): Collection
2{
3    return app(InlineMetadataInterceptor::class)->intercept($this)->middleware;
4}

Now, the intercept method works the magic to resolve the ['auth'] middleware we defined in the blade file:

 1public function intercept(MatchedView $matchedView): Metadata
 2{
 3    if (array_key_exists($matchedView->path, $this->cache)) {
 4        return $this->cache[$matchedView->path];
 5    }
 6
 7    try {
 8        $this->listen(function () use ($matchedView) {
 9            ob_start();
10
11            [$__path, $__variables] = [
12                $matchedView->path,
13                $matchedView->data,
14            ];
15
16            (static function () use ($__path, $__variables) {
17                extract($__variables);
18
19                require $__path;
20            })();
21        });
22    } finally {
23        ob_get_clean();
24
25        $metadata = tap(Metadata::instance(), fn () => Metadata::flush());
26    }
27
28    return $this->cache[$matchedView->path] = $metadata;
29}

There’s quite a bit happening here, but the key part is the static closure that executes the blade file. It essentially requires it to allow any code defined within the PHP opening and closing tags to be executed. So, something like middleware(['auth']) function gets triggered.

And notice in line 25, it obtains an instance of the metadata object and then flushes it.

Back to __invoke

 1public function __invoke(Request $request, string $uri): mixed
 2{
 3    $matchedView = (new Router(
 4        $this->mountPath->path
 5    ))->match($request, $uri) ?? abort(404);
 6
 7    return (new Pipeline(app()))
 8        ->send($request)
 9        ->through($this->middleware($matchedView))
10        ->then(function (Request $request) use ($matchedView) {
11            if ($this->onViewMatch) {
12                ($this->onViewMatch)($matchedView);
13            }
14
15            return $this->renderUsing
16                ? ($this->renderUsing)($request, $matchedView)
17                : $this->toResponse($matchedView);
18        });
19}

After running the middleware, it proceeds to prepare the content of the blade for display.

Conclusion

Laravel Folio is an impressive package that I’d likely use for quick MVPs and simpler projects. I appreciate its concept and how it operates internally.

Let me know your thoughts on it in the comments section below.

Happy Coding!

comments powered by Disqus

You May Also Like