- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You need a settings page. With Folio and Volt you write one Blade file, drop it in resources/views/pages/, and it works. No route entry, no controller, no Livewire class, no view binding. The page routes itself and the form logic sits right above the markup. It feels great, and for a settings page it is the right call.
Three weeks later the same pattern is holding your checkout flow. The handle payment logic lives inside a Blade file next to the wire:submit button. The pricing rules are in a closure. Nobody can test it without booting a browser. That is the trap, and it is easy to walk into because the first ten pages were genuinely better this way.
What Folio actually is
Folio is file-based page routing. A Blade file's path becomes its URL. You install it, point it at a directory, and every file there is a route.
composer require laravel/folio
php artisan folio:install
resources/views/pages/index.blade.php serves /. pages/about.blade.php serves /about. Route parameters go in brackets:
php artisan folio:page "users/[id]"
# resources/views/pages/users/[id].blade.php
<div>
User {{ $id }}
</div>
Name the bracket after a model and Folio wires up route model binding for you:
php artisan folio:page "users/[User]"
<div>
User {{ $user->id }}
</div>
Named routes and middleware live at the top of the file as function calls, not in a separate route file:
<?php
use function Laravel\Folio\{name, middleware};
middleware(['auth', 'verified']);
name('users.show');
That is the whole model. No routes/web.php entry, no controller. The file is the route.
What Volt actually is
Volt is Livewire without the class file. A normal Livewire component is two files: a PHP class and a Blade view. Volt collapses both into one, with a functional API instead of class properties and methods.
composer require livewire/volt
php artisan volt:install
A counter, whole component, one file:
<?php
use function Livewire\Volt\state;
state(['count' => 0]);
$increment = fn () => $this->count++;
?>
<div>
<span>{{ $count }}</span>
<button wire:click="increment">+</button>
</div>
state() replaces public properties. Closures assigned to variables become actions. There is computed(), mount(), rules(), on() for events. Same reactivity as class-based Livewire, less ceremony.
The two together
Folio routes the page, Volt makes it interactive, and the @volt directive lets both share one file. A contact form as a single self-routing page:
<?php
use function Laravel\Folio\name;
use function Livewire\Volt\{state, rules};
use App\Models\Message;
name('contact');
state(['email' => '', 'body' => '']);
rules([
'email' => 'required|email',
'body' => 'required|min:10',
]);
$send = function () {
$this->validate();
Message::create($this->only('email', 'body'));
$this->reset();
};
?>
<x-layout>
@volt('contact')
<form wire:submit="send">
<input type="email" wire:model="email">
<textarea wire:model="body"></textarea>
<button>Send</button>
</form>
@endvolt
</x-layout>
One file. It routes itself, validates, persists, and re-renders. For a contact form, that density is a feature.
Where this shines
The pattern earns its keep on small surfaces where the page is the feature:
- Marketing and content pages with a little interactivity.
- A settings screen that reads and writes a handful of user fields.
- Admin CRUD where the form maps one-to-one to a table.
- Internal tools and dashboards nobody outside the team sees.
- Prototypes you want on screen this afternoon.
What these share: the logic is thin, the surface is the point, and the cost of a rewrite later is low. You are not encoding rules that will outlive three framework versions. You are showing and editing rows. Folio plus Volt removes four files of boilerplate per screen and the domain barely exists, so there is nothing to protect.
The coupling trap
Now watch the same tool bend under real domain weight. Here is a checkout page that started small and grew:
<?php
use function Livewire\Volt\{state, computed};
use App\Models\Cart;
use App\Models\Order;
state(['cartId' => null, 'coupon' => '']);
$total = computed(function () {
$cart = Cart::with('items.product')->find($this->cartId);
$subtotal = $cart->items->sum(
fn ($i) => $i->product->price * $i->quantity
);
// business rules, in a Blade file
if ($this->coupon === 'LAUNCH25') {
$subtotal *= 0.75;
}
if ($subtotal > 100_00) {
$subtotal -= 10_00; // free-shipping threshold
}
return $subtotal;
});
$checkout = function () {
// tax, inventory hold, payment capture, order write...
};
?>
<div><!-- 80 lines of markup --></div>
Everything works. Everything is also stuck. The pricing rules (LAUNCH25, the free-shipping threshold, tax) live inside a computed() closure inside a Blade file that Folio maps to a URL. To test the discount math you boot Livewire and drive a fake browser. To reuse the same total on an invoice PDF or an API endpoint, you copy the closure. When the coupon logic grows a second rule, it grows here, next to the wire:model bindings.
The transport (an HTTP page) and the domain (how an order is priced) are now the same object. That is the coupling. Folio and Volt did not cause it. They made it frictionless, which is worse, because friction is what usually stops you from putting billing logic in a view.
The line
Ask one question of every page: if this logic were wrong, would the damage be visual or financial?
Visual damage (a misaligned form, a stale label, a toggle that does not persist) belongs in Folio and Volt. Cheap to fix, cheap to rewrite, no reason to protect it behind layers.
Financial or correctness damage is different: pricing, tax, inventory, permissions, anything a regulator or a refund touches. That does not belong in a Blade file. Volt is not the problem. That logic needs to be callable from a controller, a queue job, an API, and a test, without a browser anywhere in sight.
Volt has an escape hatch for exactly this: class-based components when a page outgrows the functional form. But the deeper fix is not "use the class version." It is to stop putting the domain in the component at all.
Keeping the line
The checkout page does not need less Volt. It needs the pricing to live in a plain PHP class the page calls:
<?php
namespace App\Pricing;
use App\Models\Cart;
final class CartPricer
{
public function __construct(
private CouponRepository $coupons,
) {}
public function total(Cart $cart, ?string $coupon): int
{
$subtotal = $cart->items->sum(
fn ($i) => $i->product->price * $i->quantity
);
$subtotal = $this->coupons
->applyTo($subtotal, $coupon);
return $this->withShipping($subtotal);
}
private function withShipping(int $subtotal): int
{
return $subtotal > 100_00
? $subtotal - 10_00
: $subtotal;
}
}
The Volt component shrinks back to what it is good at: holding form state and calling the domain.
<?php
use function Livewire\Volt\{state, computed};
use App\Pricing\CartPricer;
use App\Models\Cart;
state(['cartId' => null, 'coupon' => '']);
$total = computed(function () {
$cart = Cart::with('items.product')->find($this->cartId);
return resolve(CartPricer::class)
->total($cart, $this->coupon);
});
?>
<div><!-- markup --></div>
The component pulls CartPricer out of the container with resolve() inside the computed. Now the pricing has a unit test that never touches Livewire. The invoice PDF calls CartPricer too. The API endpoint calls CartPricer. The page became a thin adapter, which is all a page ever should have been.
Notice nothing about Folio or Volt changed. The routing is still file-based. The component is still one file. You just moved the part that matters out of the part that renders.
The rule to remember
Reach for Folio and Volt when the page is the product: content, settings, admin CRUD, internal tools, prototypes. Skip them — or rather, keep them at the surface and put the logic behind a plain object — the moment the page starts encoding rules your business would fight to get right. The one-file convenience is real. Just do not let it swallow your domain.
If this was useful
The whole point of Folio and Volt is to collapse the edge of your application into one file, and that is exactly right for the edge. The mistake is letting the edge and the domain become the same file. Keeping the page as a thin adapter over a plain, testable core — one that does not know or care that a Blade template called it — is the habit Decoupled PHP is built around: the architecture your Laravel codebase reaches for once a screen stops being only a screen.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)