Viewi in production

I wrote Viewi, and I run things on it. The site you’re reading this on is one of them, and nutriendoua.com is another and has been serving for years.

The newest and most demanding is Urlicer: branded short links, QR codes, team workspaces, click analytics. Its entire interface is Viewi components, server-rendered by PHP and hydrated in the browser, sharing model and validation code with the backend. The backend is Swoole. Analytics land in ClickHouse. TypeScript appears in exactly one place, wrapping third-party browser SDKs that have no PHP to speak of.

Here is how it’s put together, what the design buys, and what it asks in return.

What it is, and what it isn’t

The short version is one application instead of two. Not a PHP API on one side and a JavaScript client on the other, with a contract between them and two copies of every model. Types and models reach the UI directly. There’s no DTO layer, no OpenAPI generation, no client SDK to regenerate when a field changes. Server rendering and hydration come with it, without adopting Next or Nuxt.

Two things it’s not. It’s not a way to avoid JavaScript. You still need to know it, for debugging, for wrapping any browser SDK, and for the boundary cases in the second half of this post. It’s not a way to avoid a build step either. A Node build still bundles the output, which is normal for a PHP project now.

Against the alternatives, as trade-offs and not a ranking. Plain HTML and vanilla JavaScript means writing your own state handling and every DOM update by hand. That works until the page has real interaction in it. Livewire and HTMX keep state on the server and go back to it on every interaction. Simpler model, bound by latency. Vue or React give you a real client application, at the price of a second language, a second build and a second copy of your models. Viewi is a client application whose UI logic is PHP, rendered on the server for the first paint. If your interface is simple enough for vanilla JavaScript, you don’t need any of the four.

Per-link analytics in Urlicer: clicks over time, unique visitors, top countries and referrers
Per-link analytics. The chart, the filters and the tables are Viewi components, rendered by PHP and hydrated in place.

The shape of the app

The application is three areas: a public site, an admin console, and the member app behind sign-in, and almost every structural decision follows from keeping them apart.

Areas are bundles, and the boundary is a namespace

Components are split into lazy-loaded groups, and a group is assigned by namespace prefix rather than per file:

$config->lazyLoadNamespace['Components\\Views\\Admin\\']  = 'admin';
$config->lazyLoadNamespace['Components\\Views\\Member\\'] = 'app';

The build emits a bundle per group. In this app the admin bundle is around 505 KB and the member app around 95 KB, and a visitor who never signs in downloads neither.

The sharp edge is the default. A component matching no rule lands in the core bundle that every visitor loads, so a single admin-only widget put in the wrong folder doesn’t fail, it quietly ships to everyone. Bundle size is the only symptom, so I check the emitted sizes after a build rather than trusting the layout of the folders.

Layouts and guards are orthogonal, and it took a mistake to internalise it

A guard decides who may open a page. A layout decides what chrome surrounds it. They are independent, and conflating them produces pages that are wrong in a way that still works.

The invitation-acceptance page is the case that taught me to separate them. It lived in the member area, behind the member guard, wearing the member layout. That meant an invited user, who by definition doesn’t yet belong to the workspace, was met with a signed-in navigation shell they had no access to, gated by a guard that should never have applied. It moved to a public onboarding folder with a minimal box layout and no guard. The route never changed.

What a guard actually is

Access control on a page is one attribute:

#[Middleware([MemberGuard::class])]
class AccountPage extends BaseComponent { /* ... */ }

And the guard behind it is an injected service with one method, which either lets the navigation continue or cancels it and sends the visitor somewhere else:

#[Singleton]
class MemberGuard implements IMIddleware
{
    public function __construct(private ClientRoute $route, private AuthService $auth) {}

    public function run(IMIddlewareContext $c)
    {
        $this->auth->getUserSession(function ($session) use ($c) {
            if ($this->auth->canUseApp($session)) {
                $c->next();
            } else {
                $c->next(false);
                $this->route->navigate('/login');
            }
        });
    }
}

The detail that matters is that the decision is asynchronous. next() is called from inside a callback, so a guard can wait on a session fetch before ruling. That’s the difference between a guard you can use and one that forces every page to assume auth state is already resolved.

Two things I’d say to anyone building this, having got both wrong first.

A client-side guard is navigation, not security. It decides what the router does. It can’t decide what the server does. Every action behind it is authorised again server-side. The guard exists so a signed-out visitor lands somewhere sensible, not to protect anything.

Middleware takes arguments, which is what makes guards worth having beyond "is anyone signed in". An array instead of a bare class name passes constructor parameters:

#[Middleware([[HasTeamCapability::class, 'ViewAuditLog']])]
class MemberAuditLog extends BaseComponent { /* ... */ }

One guard class, parameterized per page, checking a named capability against the team the route identifies. It has a consequence I should mention, because it bit me: a parameterized guard must not be a singleton. Resolve it once and the first page's capability is frozen for every page after it, which fails in the worst direction: later pages get checked against the wrong permission and the mistake looks like a permissions bug rather than a lifetime bug.

Worth separating two questions that look alike here. Whether this member may view the audit log is a role question, and the guard answers it. Whether this workspace's plan includes the audit log at all is a billing question, and the page answers that one by rendering an upgrade card instead of a table. Routing the second through a guard would’ve bounced paying-curious users to a dashboard with no explanation. Both are re-checked server-side regardless.

Put the predicate in one place, and name it. Two guards once had their own copy of a plausible-looking condition on the user record, and both silently locked out brand-new signups: the flag they tested is false until email confirmation, while login already admits those accounts. The result was a loop with no message. Sign in, get returned to the login page, nothing on screen explaining why. A guard that’s wrong doesn’t throw. It quietly makes a page unreachable, and that is the least debuggable failure in this list. One named method, canUseApp(), on the auth service, and every guard asks the same question.

Component libraries are composer packages

Components don’t have to live in your application. A package declares where its components, JavaScript and assets are, and what it depends on:

class FluffyPupils extends ViewiPackage
{
    public static function getComponentsPath(): array { /* ... */ }
    public static function jsDir(): ?string          { /* ... */ }
    public static function assetsPath(): ?string     { /* ... */ }

    public static function getDependencies(): array
    {
        return [ViewiUI::class];
    }
}

Dependencies resolve transitively, so the app registers one package and gets a graph. This site uses the Paws app layer, which pulls in Viewi UI, which pulls in the icon set. A single use() call, and all three are MIT.

What makes it more than a convention is that packaged components aren’t special. They enter the same build as yours: the same transpile, the same CSS tree-shaking, the same lazy-load grouping, and the same ability to be replaced. A component library isn’t a black box you theme from outside. Its pages and its inputs are PHP classes you can extend, which is what the next section is about.

One startup hook, and a package becomes configurable from the app

A package that ships an admin console has to let you add to its menu. A localization service has to load its strings from somewhere the package can’t know about. A rich-text component has to be told how to upload a file. The usual answers are a configuration array that grows into a language, or a service locator. Viewi's answer is an interface with one method:

interface IStartUp
{
    function setUp();
}

Implementations are constructor-injected and run when the app starts, on the server and in the browser. What they do varies more than you would expect from the size of the interface.

The application adds its own entries to an admin menu owned by a package, and the package never learns of it:

class AdminMenuItemsStartUp implements IStartUp
{
    public function __construct(private AdminMenuService $menu) {}

    public function setUp()
    {
        $this->menu->addMenuItem(new MenuItem('Short URLs', 2, '/admin/short-url', 'bi-share'));
    }
}

Localization fetches its resource table over the same isomorphic client the components use, then makes its lookup available to every template in the application. A mechanism of its own, further down.

And a package configures a third-party-backed component in the same place. The rich-text editor is handed its file-upload adapter with one assignment:

public function setUp()
{
    CKEditor::$fileAdapter = fn($loader) => $this->getAdapter($loader);
}

Menu extension, data loading and third-party wiring aren’t obviously the same problem. That they go through one hook, with dependencies arriving by constructor rather than by lookup, is the part worth stealing even if you never use Viewi.

Overriding what a package already ships

That layer ships working pages: auth, admin CRUD, a blog, menus, media. Being able to use them and still replace individual ones is what makes a package worth depending on, so a component can claim another's identity:

#[OverrideComponent(BlogPostPage::class)]
class ViewiBlogPostPage extends BlogPostPage
{
    // ...
}

This page is rendered by exactly that: the stock blog post page, subclassed to add syntax highlighting, claiming the original's route without touching the package or duplicating a route registration. The alternative, registering the same path at a higher priority, works too, but it leaves two routes for one page and depends on the paths matching exactly.

The second use is the one I reach for more often, and it inverts the idea. A package can render a component that’s deliberately empty. A named place in its layout that exists only to be replaced. The base layout renders an empty assets component at the end of every page's body, and the application overrides it:

#[OverrideComponent(BaseBodyAssets::class)]
class UrlicerBodyAssets extends BaseComponent
{
    public function __construct(ConfigService $config)
    {
        $this->pirschKey = $config->get('pirschKey');
    }
}

That’s how the brand stylesheet and the analytics tag get onto every page of the application without a single edit to the package's layout, and without the package anticipating either of them. It’s a slot that reaches across a package boundary, resolved at build time rather than passed down through props.

Its template then does something I didn’t expect to need: a component rendered at the end of the body contributes to the head, because stylesheets are collected into named bundles during the build rather than emitted where they appear. <CssBundle to="paws-head"> portals a stylesheet into the head bundle from a component that renders nowhere near it.

What the design buys

One model layer, not two

The reason the transpiler exists isn’t rendering. Rendering could have been solved with a template engine and some JavaScript. The reason is that a component and everything it depends on, its models and its validation, are ordinary PHP classes that end up running in both places.

Validation is where that pays. A rule set is a class over a model, built fluently:

class MenuItemValidation implements IValidationRules
{
    public function __construct(private MenuItemModel $item) {}

    public function getValidationRules(): array
    {
        return ValidationRules::rules($this->item)
            ->required('Name')
            ->maxLength('Link', 400)
            ->toList();
    }
}

The rules are closures over the model instance, which is the whole trick: they’re plain PHP, so they transpile like anything else, and they’re evaluated rather than described. There’s no schema format, no generator, and no build step whose job is to keep two copies in agreement.

The controller constructs that class over the model it just bound from the request body and runs it:

$rules = (new LanguageValidation($language))->getValidationRules();
// ... any rule that does not return true becomes a message
if (count($messages) > 0) {
    return $this->BadRequest($messages);
}

The form constructs the same class over the model the user is editing, and the messages surface through the form's validation context into whichever inputs they belong to. Same rules, two jobs: in the browser they’re immediate feedback, on the server they’re enforcement. Exactly the division the guards use: one side is user experience, the other is the only side that decides anything.

What this deletes is the category of bug where the two disagree: a form that submits happily and comes back 422, or worse, a client rule that’s stricter than the server's and quietly makes valid input unenterable. On a product with workspaces, invitations, quotas and per-plan limits, that category is large.

The part I’d change is the server side of that snippet. Every handler that validates repeats the same loop over rules and messages. That’s boilerplate the framework should own. One middleware could run the rule set for any handler whose bound model has one. It works, but it’s the kind of repetition that eventually gets edited in eleven places and missed in the twelfth.

A link options form: custom alias, branded domain, UTM tags, expiry, geo targeting
One form, one set of rules. What the browser rejects and what the server rejects are the same class.

The same component model renders the emails

Transactional email is HTML written to please clients that stopped improving in 2007, and it’s usually a separate templating system for that reason. Here it’s the component model again. An email is a component with a layout, composed the same way a page is, except these components are marked as never reaching the browser:

// once, for a whole namespace
$config->noJsNamespace[] = 'Components\\Emails\\';

// or per component
#[Skip]
class ResourceAlertEmail extends BaseComponent
{
}

Two things follow, and the second is the one I didn’t anticipate when adding it.

They cost the bundle nothing. Dozens of email templates, none of them transpiled, none of them shipped. The frontend has no idea they exist.

And because they’re never transpiled, the transpiler's subset of PHP doesn’t apply to them. Inside a page component you write PHP that must have a faithful JavaScript counterpart. Inside an email component you write PHP. Ordinary helpers, ordinary formatting, no boundary to think about. It is a useful reminder that the constraint belongs to the transpile step, not to the component model.

Being components, they’re also renderable anywhere. An admin page previews every email by rendering it, which beats sending yourself test messages.

The caveat is email's, not Viewi's: Gmail strips <style> blocks and class attributes, so email components have to be styled with inline attributes. Worth knowing before you reach for the CSS pipeline that works everywhere else on the site.

Where PHP stops, and what happens there

Write-once has a hard edge: a browser SDK has no PHP. Paddle's checkout, CKEditor, Monaco. These exist as JavaScript and nothing about a transpiler changes that. Pretending otherwise would be the kind of framework promise that costs you a week when it turns out to be false, so the answer is an explicit hatch rather than a claim.

The component stays PHP and becomes a shell: typed props, lifecycle, and empty bodies for the methods JavaScript will own.

#[ExtendWithJs]
class PaddleCheckout extends BaseComponent
{
    public string $clientToken = '';
    public string $transactionId = '';

    /** Implemented in JS; a no-op on the server. */
    public function initPaddle() {}
    public function teardown() {}

    public function rendered() { $this->initPaddle(); }
    public function destroy()  { $this->teardown(); }
}

The JavaScript imports the transpiled component and patches its prototype:

import { PaddleCheckout } from "../../app/main/components/.../PaddleCheckout";

PaddleCheckout.prototype.initPaddle = function (this: any) {
    // load Paddle.js, open the inline checkout, emitEvent('completed') when done
};

Server rendering emits the inert container and nothing else, because the hooks that call into JavaScript are client-side ones. The empty PHP bodies aren’t a formality either. They are the contract, typed and visible from the PHP side, so what the JavaScript must provide is documented where you read the component rather than in the module that patches it.

This isn’t a niche facility, and the evidence is that the component library uses it more than the application does: the rich-text and code editors in Viewi UI are exactly this shape. The hatch is load-bearing for the framework itself, which is a better argument for it than any I could make.

One placement note that cost me an hour, and which belongs with the bundle rules above: a component owned by a lazy group is registered only in that group's chunk. This checkout is used by both the member billing page, inside the lazy app bundle, and the public payment page, served from the core bundle, so it has to live in a shared namespace. Put it in the member area and the public page mounts nothing at all, silently.

An HTTP client that’s the same object in both places

Components call the API through one client, and interceptors compose per request: withInterceptor() returns a new client with that interceptor added and leaves the original untouched, so a call can opt into session handling, CSRF or custom error behaviour without any of it leaking to the next caller.

Because it’s the identical class on both sides, an interceptor can’t be correct on the server and missing in the browser. That’s the failure mode you get when the two clients are separate implementations that drifted.

Being explicit at the call site is a trade-off, and I’m no longer sure I chose right. It reads clearly and it composes, but this application has around thirty-five mutating calls each attaching the CSRF interceptor by hand, and a call that forgets is a bug nothing catches at build time. It fails safe, since the server rejects the request rather than accepting it, but it fails at runtime, where a default applied to mutating verbs would’ve caught it at the point of writing. That’s the change I’d make.

Context instead of prop drilling

Frontend dependency injection has real scopes, and two of them do the interesting work. A form asks for its validation context per component instance:

public function __construct(
    #[Inject(Scope::COMPONENT)]
    private FormContext $form
) {}

An input asks for the same class from its nearest ancestor, and tolerates not finding one:

public function __construct(
    #[Inject(Scope::PARENT)]
    private ?FormContext $form = null
) {}

That pair is the whole design. COMPONENT means every form on a page gets its own context, so a page with a sign-in form beside a registration form, which is exactly what the invitation-acceptance page is, keeps two sets of errors apart without either form knowing the other exists. PARENT means an input finds its form however deeply it’s nested, through whatever fieldsets, columns and wrappers sit between them. Nullable means an input outside any form is still a working input rather than a crash.

Inputs then register themselves on mount, keyed by name:

$this->form->inputs[$this->name] = function ($valid, $errors) {
    $this->isInvalid = !$valid;
    $this->validationMessages->show = $this->isInvalid;
    $this->validationMessages->messages = $errors;
};

So validating a form is one call. The context runs the shared rule set from earlier, and routes each field's messages to the callback that field registered. Anything it can’t match, a server message about the submission as a whole rather than a field, goes to a fallback message component instead of being dropped.

What is absent is the point. The page doesn’t hand inputs their errors. Inputs don’t accept an errors prop, and the wrappers between them don’t forward one they have no interest in. Add a field and it participates. Move it into a new column and it still participates. The one thing you must get right is the name, because that’s the key the messages are routed by, and a misspelt name fails quietly, with the message landing in the fallback rather than under the field.

A service method can become a template global

A few things are wanted by almost every template: translate a key, format a timestamp. Injecting a service into two hundred components so each can call one method on it is noise, and a static helper class solves it by giving up dependencies and state.

Marking a method instead makes it callable directly in any template:

#[GlobalEntry]
public function t(string $key, ?array $params = null)
{
    return $this->resources[$key] ?? $key;
}

Then any component's markup can use it, with no import, no injection and no prop:

<title>{ t('layout.title') }</title>

What keeps this from being a global function in the bad sense is that it’s still a method on a real service, and the localization service is the one to look at, because it uses both mechanisms at once. It implements IStartUp, so at boot it fetches its resource table over its own injected HTTP client. It marks t() as a global entry, so every template can read that table by name. One class, loading itself and publishing itself, with no registry in between and no static state anywhere.

So a template global here has dependencies and a lifecycle. It simply also has a short name.

Both of the ones this site uses, translation and date formatting, come from the app layer rather than the application, so this is another thing a package contributes. That’s the reason to keep the list short: a global entry is a name that every template in every package now shares, and the cost of the convenience is paid in that namespace.

Confirmations are a service call, not page state

A destructive action needs a confirmation dialog, and the usual cost of one is a boolean on the page, a pending-action property, some markup, and a pair of handlers. Here it’s an argument and a callback:

$this->modal->confirm(
    'Revoke "' . $key->Name . '"? Any integration using this key will stop working.',
    function () use ($key) {
        $this->http->delete('/api/app/' . $this->teamId . '/api-keys/' . $key->Id)
            ->then(
                fn() => $this->alert->success('API key revoked.', 5000),
                fn() => $this->alert->error('Could not revoke the key.', 6000)
            );
    }
);

No dialog markup in the page, no flag to reset, and nothing to wire through a child component. Toasts work the same way. The dialog host lives once in the layout and the service owns the queue.

Services push, components subscribe

Reactivity covers a component's own state. It doesn’t cover state that lives in a service and concerns half the page: who is signed in, which URL is current. For that a service exposes a subscription, and the component holds the handle:

public function init()
{
    $this->sessionSubscription = $this->auth->subscribe(
        fn(?UserAuthSessionModel $session) => $this->applySession($session)
    );

    $this->pathSubscription = $this->route->urlWatcher()->subscribe(
        fn(string $path) => $this->closeMenu()
    );
}

public function destroy()
{
    $this->sessionSubscription->unsubscribe();
    $this->pathSubscription->unsubscribe();
}

One header, two unrelated concerns: it swaps its account controls when the session changes, and it closes its mobile menu when the route does. Neither is polled, and neither needs a parent to pass anything down.

The detail that makes this pleasant rather than fiddly is that subscribing also delivers the current value. The auth service fetches on subscribe rather than only emitting future changes. That removes the entire class of bug where a component mounts after the event it cared about and sits there showing a signed-out header to a signed-in user.

Unsubscribing is manual, and I kept it that way. The handle is a value the component owns, with a symmetric place to release it in destroy(). Making it implicit would hide a lifetime you genuinely want to see.

Rendering out of order, because a document has an order

Server rendering brings back a constraint that client frameworks abolished. A document is written from the top down, so anything already emitted is gone. A client framework never has this problem. It patches a live DOM and can touch the head at any point in the page's life. Under SSR, a value that’s only known after the body has rendered can’t reach a meta tag that was written before it.

The answer is a component that defers:

<DelayRender>
    <div>Page score is $calculatedTotal</div>
</DelayRender>

What it does at render time is emit a placeholder token where it stands, and register a post action. Once the entire response body exists, the slot is rendered, with everything now known, and substituted into the body in place of the token. So the markup appears where you wrote it, and is produced after everything that informs it.

It pairs with the named CSS bundles mentioned earlier. Both answer the same question in different directions: a component that renders late can place content early, and a component that renders low can contribute to a region near the top. Between them, position in the document stops dictating order of computation.

Worth being straight that this is a cost of server rendering rather than a feature that came free with it. SSR buys real HTML on first paint and pays for it with ordering constraints, and I’d rather ship a documented component for that than pretend the constraint isn’t there.

Updates that are precise by construction

A binding records the properties it reads while it evaluates, and that record is what a change consults. There’s no virtual DOM diff and no component-level invalidation: a property changes, and the bindings that actually read it update. Hydration works the same way. The runtime anchors to the server's DOM and attaches state to it rather than re-rendering over the top.

What it sits on

Worth stating because it explains why server rendering is affordable here. The backend is Swoole with coroutine hooks enabled, so database and network calls are non-blocking without any of the code being written in an async style. Each request gets a fresh scoped DI container that disposes on completion. That’s what returns the PostgreSQL connection to the pool and makes cross-request state bleed structurally difficult rather than merely discouraged.

Rendering a page server-side is therefore a function call in a warm worker with a pooled connection, not a process spawn. That’s the difference between SSR being a nice idea and SSR being the default.

Measured on 2026-09-08, fifteen requests per page, taking the median of the Server-Timing: app;dur header both sites already send:

PageSizeServer renderRange
viewi.net/docs/introduction60 KB1.33 ms1.23 - 1.70
viewi.net/77 KB1.63 ms1.48 - 2.52
urlicer.com/url-shortener-api98 KB1.50 ms1.31 - 1.88
urlicer.com/108 KB2.64 ms2.33 - 3.56
viewi.net/blog103 KB3.15 ms2.84 - 5.76
viewi.net/blog/viewi-in-production126 KB3.24 ms3.02 - 5.16

The last row is this page. Two caveats before anyone else raises them. That header is the application's own measurement, taken with hrtime around the middleware chain, so it covers routing, the controller and the render, and excludes nginx, TLS and the network. And these boxes aren’t under load, so read it as a floor rather than a benchmark.

The useful part is that you don’t have to take my word for any of it. Run curl -D - https://viewi.net/blog/viewi-in-production and read the header yourself.

Caveats, before you write components

Assignment is what reactivity observes

State is wrapped in proxies, and a proxy intercepts access on the object it wraps. Making $this->items[] = $row reactive would mean recursively wrapping every array and object hanging off a component and re-wrapping on every write: allocation on paths that run constantly, in exchange for a mental model where some mutations are observed and others aren’t depending on how deep they sit. Viewi observes assignment instead, so there’s one rule rather than a depth chart.

$this->items[] = $row;      // items.push(row), nothing observes this
$this->items[$i] = $row;    // index write, likewise

The near-miss to watch for: $next = $this->items is a reference in JS rather than a copy, so assigning it back changes nothing. A genuinely new array is what re-renders.

$next = [];
foreach ($this->items as $existing) {
    $next[] = $existing;
}
$next[] = $row;
$this->items = $next;

Derived state belongs in a property

Tracking follows evaluation, which is what makes it precise, and it means a binding only knows about the reads on the path it actually took. A foreach over a property is reactive. Over a method call it’s not. A binding that calls a method which delegates through two or three others registers what that path read and nothing deeper. A boolean built from a chain of || stops evaluating at the first truthy operand and stops recording there too.

The way this shows up in practice is a chart. The dots were driven by a foreach over a property and moved when the filter changed. The polyline's points came from a method four calls deep, so its dependency was never recorded and it stayed where it was. A line detached from its own data points is a memorable way to learn where the boundary of dependency tracking sits.

So compute derived values into a property where the framework can see them:

public array $gridLines = [];

public function mounted()   // props are set before mounted(), not before init()
{
    $this->refreshGrid();
    $this->watch('points', fn() => $this->refreshGrid());
}

Making this transparent would mean evaluating methods speculatively or analysing the call graph at build time. Both cost more, in build complexity and in surprising behaviour, than the idiom does. It’s the trade-off in Viewi I’m least comfortable with and still think is right.

Components fetch, they don’t mutate

When a component fetches while the server is rendering, the bridge re-invokes the router in-process rather than making a real HTTP request, and the response is handed to the client so the browser doesn’t repeat it. One round trip instead of two, and no socket back to a server that’s already executing the code you want.

The corollary catches people: a component whose mounted() performs a POST performs that write while the server renders a plain GET. It shows up only on a direct open of the page, because navigating to the same route client-side has the browser issue the request itself, so a page can work when you click into it and misbehave when you paste its URL.

Keep mutations in user actions. That’s good practice under any framework. Here it’s load-bearing.

CSS is tree-shaken against literals

The build removes any class not referenced as a literal string in the source. That’s how the bundle stays small without a separate purge step to configure. The cost is that a class name assembled at runtime is invisible to it:

// the rule for btn-outline-success is stripped: that string never appears
return 'btn-outline-' . $unit['color'];

The element renders, unstyled, and nothing warns you. Writing the variants out as literals in a switch is uglier and correct. This is the one place where I’d accept a build-time warning over elegance, and it’s on the list.

What crosses into the browser, and what can’t

The first question anyone sensible asks about compiling PHP to JavaScript is whether their server code is now in the bundle. It’s the right question, and the answer is that the boundary is a directory rather than a judgement call.

The build walks only the registered component paths: the application's own, plus one per package. A component that uses another class inside those paths pulls it in, transitively. A use pointing at a class outside them isn’t quietly dropped and not quietly included:

throw new Exception("Class '$className' can not be found.");

So a component that reaches into the service layer, the repositories, or anything else living outside the component tree fails the build. You can’t leak your backend by accident, because the accident doesn’t compile. #[Skip] then excludes a class that does live in the tree but should not ship.

What remains your responsibility is what you deliberately put in a component, and there are two ways to get that wrong. A constant, a default value or a hardcoded key written into a component class is code in the component tree, and it ships. And public component state is serialized into the page so the browser can hydrate it. A public property is visible whether or not you think of it as server-side. "It’s only used during rendering" isn’t a security property.

The rule that follows is short enough to hold: components carry view state, and anything that must not reach a browser lives outside the component path, where the build can’t reach it even if you ask it to.

The transpile boundary is the sharp edge

Write-once means every PHP builtin used inside a component needs a faithful JavaScript counterpart, and that’s a long tail to keep honest. Where a counterpart's signature drifts, a return type that’s an int in PHP and a boolean in JS, you get code that’s correct on the server and wrong in the browser, silently, because server rendering still produces the right HTML.

I treat those as bugs rather than documented behaviour, and I’d rather make the class of problem impossible than describe it well. Until then, here’s the tell: if a condition holds when you check it with curl and never fires in a browser, suspect the boundary before you suspect your logic.

Where it stands

Across those sites the write-once premise has carried its weight, and the work left isn’t in the ambitious parts. Transpiling PHP to JavaScript and rendering the same component twice is settled. What still costs me time is the quiet class of failure at the edges: a builtin whose return type disagrees across the boundary, a class name the tree-shaker can’t see, a component in the wrong namespace shipping to every visitor. All three are silent, and all three pass server-side. Making them loud is more valuable now than any new feature.

This is also not a tour. It’s the subset that running a product for a few years actually pressed on. That leaves out a good deal: portals, named slots, template refs, lazy-loaded route sections, interceptors beyond the one mentioned, localization, and most of the UI kit: tables with sorting and inline editing, date pickers, tabs, accordions, dropdowns, transitions, and the rich-text and code editors. Some of that I use daily and it simply never caused an argument worth writing down, which is the best thing you can say about a component.

Docs are at viewi.net/docs, source is on GitHub.

Written with Claude, working from this codebase and my notes. The architecture, the decisions and the mistakes are mine.