This page helps you decide where front-end code should live in an Assegai project.
If your question is "Where do I put my JavaScript without turning the app into a pile of browser scripts?", start here.
The core idea is simple:
- start with server-rendered HTML
- add HTMX when you want server-driven interaction
- add Web Components when a piece of UI needs browser-side behavior
- keep global one-off browser code in
public/js/main.js - keep first-party Assegai Web Components in generated
.wc.tsfiles undersrc/
By the end of this guide, you should know where front-end code lives, how to generate new components, and how to run the supported build/watch flow.
Server-rendered pages often use Twig
If you are new to Assegai's frontend story, it helps to separate two different jobs:
- Twig renders HTML on the server
- Web Components add browser behavior where you need it
So when you see a file like AboutComponent.twig, think "server-rendered HTML template," not "browser component code."
That means a page can use Twig for the HTML structure and still hydrate a Web Component later for interactive behavior.
The default path for a new app
For a new project, the happy path is:
- render HTML from a
Viewor a component-backed page - generate a Web Component when a specific UI element needs client-side behavior
- run
assegai serve --devwhile you work - let Assegai inject the bundle automatically once it exists
That keeps the server in charge of the page while giving you a clean place for interactive browser code.
Where code belongs
src/Views/*.php
Use classic views when:
- the page is mostly HTML plus server data
- you do not need a feature-specific page component yet
- you want the fastest possible path from controller to HTML
src/<Feature>/...Component.php and .twig
Use component-backed pages when:
- the page belongs to a feature module
- the template, service, controller, and module should stay together
- the page still wants to be server-rendered first
The .twig file here is a normal Twig template. It is the HTML side of the page.
public/js/main.js
Keep main.js for:
- tiny page-level DOM helpers
- third-party scripts that are not part of the Assegai Web Components pipeline
- truly global browser behavior that does not need to become a reusable custom element
Do not treat main.js as the main home for new first-party Assegai Web Components. Those belong in .wc.ts source files so the CLI can discover, bundle, and watch them.
src/**/*.wc.ts
Use .wc.ts files for:
- new custom elements generated with
assegai g wc ... - browser-side runtime files paired with generated pages or components via
--wc - reusable UI elements that should be bundled into the Assegai Web Components runtime
Create your first Web Component
Generate a standalone Web Component:
assegai g wc ui/alert
Or pair a page or component with a runtime file:
assegai g component user-card --wc
assegai g pg about --wc
That gives you a server-rendered feature plus a browser-side custom element file that participates in the first-party build flow.
Twig basics in Assegai
The most common Twig syntax you need is:
{{ title }}to print a value{% if subtitle %}for conditions{% for item in items %}for loops{# comment #}for template comments
Example:
<section class="hero">
<h1>{{ title }}</h1>
{% if subtitle %}
<p>{{ subtitle }}</p>
{% endif %}
<ul>
{% for feature in features %}
<li>{{ feature }}</li>
{% endfor %}
</ul>
</section>
If you already know Twig from somewhere else, that same template language knowledge still applies here.
What Assegai passes into a Twig template
Component-backed Twig templates receive:
- the component's public properties as normal variables
- a
ctxhelper object for methods and framework helpers
So if your component has:
public string $title = 'Dashboard';
public function greeting(): string
{
return 'Welcome back.';
}
you can use:
<h1>{{ title }}</h1>
<p>{{ ctx.greeting() }}</p>
That is the rule to remember:
- public properties become plain Twig variables
- public methods are called through
ctx
Built-in ctx helpers include:
ctx.config(...)ctx.translate(...)ctx.timeAgo(...)ctx.env(...)ctx.getLang()ctx.webComponentProps(...)andctx.wcProps(...)ctx.webComponentBundleUrl()
A generated component is expected to render through the shadow root
Generated Web Components use the Assegai runtime and attach a shadow root automatically. A typical render method looks like this:
protected render(): void {
const name: string = this.getAttribute('name') || 'user-card';
this.shadow.innerHTML = `
<style></style>
<p>${name} works!</p>`;
}
That means:
- render into
this.shadow - keep component styles inside the shadow tree when you want local encapsulation
- think of the generated runtime as the normal Assegai Web Components shape for new components
If a component is present in the HTML but its client-side behavior never appears, the most common issue is not the shadow DOM itself. It is usually that the bundle was never built, never watched, or never injected.
Run the front-end workflow
The first-party commands are:
assegai wc:list
assegai wc:build
assegai wc:watch
wc:list answers a very useful question immediately: did the CLI actually discover the Web Components you think it should be bundling?
For development, the easiest loop is:
assegai serve --dev
That starts the PHP dev server and the Web Components watcher together.
If you prefer two separate terminals, this is still valid:
assegai serve
assegai wc:watch
For most new projects, serve --dev is the easiest starting point.
How props move from PHP into a Web Component
From Twig:
<app-user-card data-props='{{ ctx.webComponentProps({
name: user.name,
role: user.role
}) }}'></app-user-card>
From a PHP view:
<app-user-card
data-props='<?= web_component_props([
"name" => $user["name"],
"role" => $user["role"],
]) ?>'
></app-user-card>
data-props is not a special Assegai-only format. It is just JSON stored in an HTML attribute.
The helper exists because JSON inside HTML needs to be encoded and escaped safely. Quotes, apostrophes, and other characters can break the markup if you try to hand-build the string. web_component_props(...) and ctx.webComponentProps(...) do that safely and give PHP views and Twig templates one consistent way to pass data.
The runtime reads that data-props payload and hydrates the custom element in the browser.
Do not assume every Twig helper from other frameworks exists
Assegai uses Twig, but it does not automatically ship every helper you may have seen in Symfony or other stacks.
Do not assume these are available unless you add them yourself:
- a global
appobject path()orurl()routing helpersasset()helpersform_*()helperscsrf_token()- third-party Twig extensions you have not installed
So the safe mental model is:
- standard Twig language features work
- Assegai-specific helpers are available through
ctx - anything beyond that should be treated as opt-in
Bundle configuration lives in assegai.json
The basic shape is:
{
"webComponents": {
"enabled": true,
"prefix": "app",
"output": "public/js/assegai-components.min.js"
}
}
Important keys:
enabledcontrols whether automatic bundle injection is activeprefixcontrols generated selectors such asapp-user-cardoutputcontrols where the browser bundle is written
If a bundle exists at the configured output path, Assegai injects it automatically into rendered HTML.
Global favicon and scripts are configured once in config/default.php
You do not need to add a favicon or site-wide scripts per page anymore.
<?php
return [
'app' => [
'title' => 'Blog API',
'favicon' => ['/favicon.ico', 'image/x-icon'],
'links' => ['/css/style.css'],
'headScriptUrls' => ['/js/main.js'],
'bodyScriptUrls' => ['/js/analytics.js'],
],
];
That configuration applies to both classic View rendering and component-backed rendered pages.
FAQ
Why are my new Web Components not rendering?
Check these in order:
- Is the custom element tag actually present in the rendered HTML?
- Does
assegai wc:listshow the component? - Did you run
assegai wc:watch,assegai wc:build, orassegai serve --dev? - Does the rendered page include the Web Components bundle?
- Are you rendering into
this.shadowin the generated runtime style?
What if my project is older and everything lives in public/js/main.js?
Keep the old browser code working first, then migrate gradually:
- Leave existing global scripts in
public/js/main.js. - Generate new custom elements with
assegai g wc ...or pair feature generation with--wc. - Put those new custom-element definitions in
.wc.tsfiles instead of appending them tomain.js. - Run
assegai wc:listand confirm the new components are discovered. - Run
assegai wc:watchorassegai serve --dev.
This lets you move onto the first-party runtime without rewriting every older browser script at once.
A good default strategy
Keep the server in charge of HTML.
Use main.js sparingly for truly global behavior. Use first-party .wc.ts files for new custom elements. Let serve --dev or wc:watch keep the bundle current while you work.
That keeps the application modular, predictable, and much easier to explain to the next person who joins the project.
Where to go deeper
Use Pages and Components for the Assegai-specific rendering model and when to choose View, Twig-backed page components, HTMX, or Web Components.
Use the official Twig docs for the template language itself: