Blade, Inertia and Filament, on purpose
Three rendering worlds share one Laravel application: Blade for the public site, Inertia and React for the product, Filament for the admin console. We chose all three up front and would choose them again. What follows is what the seams between them actually cost, in code.
Tarek Morgene (CTO, NessFlow) · · 10 min read
Laravel 13, Inertia 3, React 19, Filament 5.
Part one covered the public site: 26 Blade views, no CDN, no front-end framework, 4,323 bytes of JavaScript. It lives beside a product of 50 Inertia pages and an admin console we did not write.
Why each world
Each of the three is here because it is the best available answer to one specific problem. The clearest way to show that is one example each.
Blade: markup and structured data cannot disagree
Server rendered by default, no hydration, no serialization bridge. The strength shows up somewhere unglamorous. Our breadcrumb component emits the visible navigation and the JSON-LD from the same array, in the same render.
<nav aria-label="{{ __('marketing.nav.breadcrumb') }}">
@foreach ($items as $item)
<span aria-current="page">{{ $item['name'] }}</span>
@endforeach
</nav>
<script type="application/ld+json">
{!! json_encode($jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
</script>
One $items, two consumers: the human and the crawler. They cannot drift, because there is nothing between them to drift through. The same page built with client rendering has two code paths to the same truth, and a rich result that silently stops matching the page is a genuinely miserable bug to find.
Inertia: typed props, and no API to maintain
An audit tool is dense: sortable tables, charts, filter panels, virtualized lists. React has mature accessible answers for all of it, and Inertia removes the expensive part of a single page application, which is the API. A controller returns a readonly DTO and the page receives it.
// Controller
return Inertia::render('issues/index', [
'crawl' => CrawlSummary::from($crawl),
'groups' => $groups,
]);
// app/Data/Seo/CrawlSummary.php, aligned with resources/js/types/seo.ts
readonly class CrawlSummary
{
/** @param array{discovered: int, crawled: int, depth: int} $stats */
public function __construct(
public int $id,
public string $status,
public int $progress,
public array $stats,
public bool $isTruncated,
) {}
}
No client store to keep in sync, no serializer layer, no second set of routes to version, no endpoint that has to be deprecated on its own schedule. Static analysis checks the PHP side, TypeScript checks the React side, and the shape is written once.
Filament: the least differentiated code in the product, for free
A back office is CRUD, and CRUD is the least differentiated code anyone writes. Two chained calls produce a searchable, sortable, paginated column.
TextColumn::make('slug')
->searchable()
->sortable(),
That is the whole argument. Its Pest integration means the panel is tested like the rest of the application rather than quietly exempted from testing, the ecosystem is unusually rich for a Laravel package, and we already know Livewire and Alpine for the rare resource that needs something specific.
One precision. Livewire is not a direct dependency. The
composer.jsonrequiresfilament/filament, there is noapp/Livewiredirectory, and we have never written a Livewire component by hand. Livewire arrives with Filament, which is a different decision with different consequences.
What one origin buys
The three worlds are served by one application, from one origin, over one session, against one database. Both build entries import the same token sheet, so the product and the public site cannot drift apart.
// app.css and marketing.css, two entries, one source of truth
@import './theme.css';
Cross world state, in this arrangement, is a cookie and a stylesheet. On separate origins it is a specification. That is the whole argument, and everything below is the price of it.
Seam one: a preference that never reaches a request
Light and dark is stored client side. Two of our worlds used two different storage keys, which did not produce two independent settings: it produced one setting that was forgotten on every crossing. A visitor who switched to dark on a public page found the product in light, and the reverse.
Both worlds now read one contract, declared twice and mirrored explicitly.
// app/Support/Appearance.php
final class Appearance
{
public const STORAGE_KEY = 'nessflow-appearance';
public const COOKIE = 'nessflow-appearance';
}
// resources/js/lib/appearance.ts
/** Mirror of App\Support\Appearance::STORAGE_KEY. */
export const APPEARANCE_STORAGE_KEY = 'nessflow-appearance';
Three details are worth more than the key itself.
There is a third copy of this predicate and it is irreducible. An inline Blade script has to resolve the theme before the first paint, which means before any bundle exists. Its parity with the TypeScript module is held by a test, so changing one without the other fails the suite.
"Auto" is the absence of a key, never the string system. Three surfaces resolve the theme before first render. A third stored value would have to be known by all three, and forgetting it in one place renders a light page on a dark machine: a theme flash nobody reproduces in development.
The cookie is not the source of truth. It is the server side mirror of local storage, and it exists for exactly one purpose: letting the Inertia shell put class="dark" on <html> before any script runs. On divergence, local storage wins, because it is the one that survives a cookie purge. Reading it also has to be wrapped, since in private browsing the read itself throws and would take the theme render down with it.
The test that had to be written against the machine
No feature test can see any of this, because nothing is wrong with either HTTP response. The guard is a browser test, and it has one property worth copying.
it('carries the theme chosen on the public site into the product', function () {
$page = visit(SlugRegistry::path('home', 'fr-FR'))->inLightMode();
$page->assertNoJavaScriptErrors()
->click('footer [data-appearance-choice="dark"]');
$page->navigate('/login');
expect(documentIsDark($page))->toBeTrue(
'The product forgot the theme chosen on the public site.'
);
});
The visitor picks dark on a machine set to light, and in the mirrored test light on a machine set to dark. Without running against the system preference, both worlds could simply follow the operating system on their own and the test would pass on precisely the broken state it exists to catch.
Seam two: two definitions of the current language
The server derived the locale from our cookie and fell back to Accept-Language. The front end read the same cookie and fell back to French. On the first request of a session, before the cookie exists, a browser configured in English produced English validation messages, an English <html lang> and English exports, inside an interface that was entirely French.
The fix was a deletion. The header branch could produce no benefit, because the front end never reads that header, so the interface stayed French regardless. It could only manufacture divergence.
// app/Http/Middleware/SetLocale.php
public const CODES = ['fr', 'en'];
/** The fallback is FRENCH, the front-end runtime's own, and NOT
config('app.locale'), which is 'en' and would reopen the divergence. */
public const FALLBACK = 'fr';
public static function resolve(mixed $declared): string
{
return is_string($declared) && in_array($declared, self::CODES, true)
? $declared
: self::FALLBACK;
}
The predicate is isolated in a static method so it can be argued with. Nothing else takes part: no header, no request body. Removing one branch corrected ten call sites at once instead of propagating a workaround into each of them.
A locale predicate is set on both sides of a boundary or on neither. Outside HTTP, in a queued job or a console command, there is no request and no cookie, so the language of a generated artifact travels as a parameter rather than being inferred.
Stated plainly, because it is a real trade: the product no longer detects browser language. It never did on screen. If detection becomes a requirement it belongs to the front end, which can write the cookie, the only place where the answer is true on both sides.
Seam three: leaving the single page application
Signing out is an XHR request. Fortify answered 302 to /, the client followed the redirect, received the complete HTML of the marketing home, found no X-Inertia header in it, and opened its <dialog id="inertia-error-dialog"> with the public site inside an iframe.
No error. No log. The user sits on /dashboard with the marketing site overlaid on top, address bar unchanged, already signed out.
// app/Http/Responses/LogoutResponse.php
public function toResponse($request): Response
{
return $request->wantsJson()
? new JsonResponse('', 204)
: Inertia::location(Fortify::redirects('logout', '/'));
}
Inertia::location() answers 409 with an X-Inertia-Location header, which the client translates into a full page load. Outside an Inertia request, the same call degrades to an ordinary redirect, so the contract of the route does not change for a bare form, an HTTP test or an API client.
Four exits cross worlds this way: signing out, deleting an account, leaving impersonation, and heading to checkout. The guard is unusual in that no content assertion can express it.
// Only the response CODE says it. Nothing in the body moves.
$response->assertStatus(409)
->assertHeader('X-Inertia-Location', '/');
$this->assertGuest();
Seam four: the panel is authenticated, so it sees the tenant scope
56 of our 77 models carry a trait whose global scope keys on Auth::user()?->current_team_id. That scope is the backbone of tenant isolation. The Filament panel is authenticated, so every Eloquent read of a business model inside it is silently restricted to the administrator's team rather than to the team on the row being looked at.
The symptom is treacherous because nothing raises. A withCount('projects') on a list of teams returns the administrator's project count on their own row, and zero on every other row. A back office showing "0 projects" across an entire fleet looks like an empty fleet, not like a bug. Sorting on that column then ranks teams in an order that means nothing.
We shipped that in two places: the Projects column of the teams list, and the ranking of a "Top Active Teams" widget.
// app/Filament/Support/CrossTenant.php
public static function unscoped(): Closure
{
return fn (Builder $query): Builder => $query->withoutGlobalScope('team');
}
// Every panel read declares what it wants to read.
TextColumn::make('projects_count')
->counts(['projects' => CrossTenant::unscoped()])
Two decisions inside that small class matter more than the class.
The escape is per read, never global. Neutralizing the scope for the whole panel is tempting and wrong: the same human also browses the product, and a scope conditioned on the current URL is exactly the kind of rule that eventually applies at the wrong moment.
The relation constraint is not removed, only the ambient filter. projects.team_id = teams.id still holds, so the count remains the count of the right team, which is the team on the row.
The pattern
A stale storage key. A fallback chain disagreeing with another fallback chain. A redirect rendering in the wrong container. A protective scope doing its job in the wrong context.
None of them threw. All of them produced output a reviewer would approve. And no feature test could have caught a single one, because in every case the server answered correctly.
So the seams get a small, specific category of test that the middle of the application never needs: browser tests that genuinely cross from one world into another, response code assertions at the crossing points, and parity tests between surfaces that must agree. It is a handful of tests, and they are the only ones we have that can fail for reasons nothing else can express.
What it costs, and when we would not do this
- Three mental models. Free for a team that knows all three. Not free for a team learning one of them.
- Three styling contexts. The panel does not load the product stylesheet, and its theming goes through a palette rather than CSS, which is a different skill from writing Tailwind.
- The seams are permanent. Cheap once understood, but they never disappear, and every new crossing point is a new chance at a silent failure.
- It rewards one repository. Everything good here follows from one origin, one session and one database. Split those and most of the argument goes with them.
Same product, same team, we would make the same three choices tomorrow. It is not a compromise between frameworks. It is three tools doing what each is best at, sharing everything worth sharing.
Next. Part three: processing millions of server log lines with Laravel, Horizon and a hand written streaming parser.
Verified in the repository on 19 August 2026: 26 public Blade views, 50 Inertia page components, 56 of 77 models carrying the team scope, 4 cross world exits, 10 locale call sites corrected by removing one fallback branch.