How to build and integrate client-side application logic with Stimulus in this project. This aligns with .cursor/rules/05-frontend.mdc and docs/archbook.md (Client-Side Organization).
Stimulus controllers are colocated with verticals:
src/<Vertical>/Presentation/Resources/assets/controllers/<name>_controller.ts
Example: src/Account/Presentation/Resources/assets/controllers/account_settings_controller.ts
This keeps frontend code within the same vertical boundaries as the feature. Use TypeScript (.ts); avoid .js for new controllers.
To make a new Stimulus controller available to the app, you need to: (1) add its path to Asset Mapper, (2) add it to the TypeScript source_dir, (3) import and register it in bootstrap.ts, and (4) attach it in Twig with values, targets, and actions.
In config/packages/asset_mapper.yaml, add the vertical's controllers directory to paths:
framework:
asset_mapper:
paths:
- assets/
- src/Account/Presentation/Resources/assets/controllers/
missing_import_mode: strictAdd one entry per vertical that defines Stimulus controllers. Use the assets/controllers/ path under that vertical.
In the same file, under sensiolabs_typescript, add the same path to source_dir so the TypeScript compiler includes it:
sensiolabs_typescript:
source_dir:
- "%kernel.project_dir%/assets/"
- "%kernel.project_dir%/src/Account/Presentation/Resources/assets/controllers/"In assets/bootstrap.ts, import the controller and register it with a kebab-case name:
import AccountSettingsController from "../src/Account/Presentation/Resources/assets/controllers/account_settings_controller.ts";
const app = startStimulusApp();
app.register("account-settings", AccountSettingsController);
webuiBootstrap(app);The name you pass to app.register() is the one you use in Twig: stimulus_controller('account-settings', ...), stimulus_target('account-settings', '…'), stimulus_action('account-settings', '…').
In the template, attach the controller and pass values (URLs, IDs, flags):
<div {{ stimulus_controller('account-settings', {
saveUrl: path('account.presentation.save'),
userId: user.id|default('')
}) }}>Use targets so the controller can find DOM elements:
<div {{ stimulus_target('account-settings', 'messages') }}>…</div>
<input {{ stimulus_target('account-settings', 'email') }} …>
<button {{ stimulus_target('account-settings', 'submit') }}>…</button>Bind actions to DOM events (e.g. submit, click):
<form {{ stimulus_action('account-settings', 'handleSubmit', 'submit') }}>stimulus_action(controllerName, methodName, event). Omit the third argument to use the default event for the element (e.g. submit for forms, click for buttons).
import { Controller } from "@hotwired/stimulus";
export default class AccountSettingsController extends Controller {
// values, targets, connect, disconnect, actions
}Export a default class so bootstrap.ts can import it.
Use static values for data passed from Twig (URLs, IDs, booleans):
static values = {
saveUrl: String,
userId: { type: String, default: "" },
someFlag: Boolean,
};Declare typed getters so the rest of the controller is type-safe:
declare readonly saveUrlValue: string;
declare readonly userIdValue: string;
declare readonly someFlagValue: boolean;Use static targets for DOM elements the controller needs to read or update:
static targets = [
"messages",
"email",
"submit",
];Declare target accessors and presence flags:
declare readonly hasMessagesTarget: boolean;
declare readonly messagesTarget: HTMLElement;
declare readonly hasEmailTarget: boolean;
declare readonly emailTarget: HTMLInputElement;
declare readonly hasSubmitTarget: boolean;
declare readonly submitTarget: HTMLButtonElement;Always check hasXTarget before using xTarget if the target can be missing in some views.
connect(): Runs when the controller's element is attached to the DOM. Use it to set up state, fetch, or subscribe to other components (e.g. LiveComponent events). Can beasync.disconnect(): Runs when the element is removed. Use it to remove listeners, cancel work, or drop references so the controller can be garbage-collected.
async connect(): Promise<void> {
this.boundHandler = this.handleEvent.bind(this);
await this.setupDependency();
this.someElement.addEventListener("change", this.boundHandler);
}
disconnect(): void {
this.someElement?.removeEventListener("change", this.boundHandler);
this.cleanup();
}Bind handlers in connect and keep a reference so you can remove the same function in disconnect.
Action methods receive the Event:
handleSubmit(event: Event): void {
event.preventDefault();
// …
}For submit, always preventDefault() if you are doing a custom fetch instead of a normal form POST. Disable inputs during the request and re-enable in finally to avoid double submit and to reflect loading state.
Use fetch with the URL from values. For forms, include CSRF (e.g. from a hidden _csrf_token or default_csrf_tag()), and set X-Requested-With: XMLHttpRequest if the backend treats that as AJAX:
const form = (event.target as HTMLElement).closest("form");
const csrfInput = form?.querySelector('input[name="_csrf_token"]') as HTMLInputElement | null;
const formData = new FormData();
if (csrfInput) formData.append("_csrf_token", csrfInput.value);
// … append other fields
const response = await fetch(this.saveUrlValue, {
method: "POST",
headers: { "X-Requested-With": "XMLHttpRequest" },
body: formData,
});Handle non‑OK responses and parse JSON or streamed bodies as needed.
-
Create
src/<Vertical>/Presentation/Resources/assets/controllers/<name>_controller.ts -
Asset Mapper
Add
src/<Vertical>/Presentation/Resources/assets/controllers/
toconfig/packages/asset_mapper.yaml→framework.asset_mapper.paths. -
TypeScript
Add the same path tosensiolabs_typescript.source_dirin that file. -
Bootstrap
Inassets/bootstrap.ts:import … from "../src/…/assets/controllers/<name>_controller.ts"app.register("kebab-name", ImportedController).
-
Twig
{{ stimulus_controller('kebab-name', { … }) }}for the element that owns the behavior.{{ stimulus_target('kebab-name', 'targetName') }}on elements the controller needs.{{ stimulus_action('kebab-name', 'methodName', 'event') }}to wire events.
-
Build and quality
mise run frontendmise quality(ESLint, TypeScript, Prettier).
When implementing polling in Stimulus controllers, always use setTimeout with scheduling after completion, never setInterval. This prevents request pile-up if the server or network is slow.
// BAD: setInterval fires every N ms regardless of whether the previous request finished
this.pollingIntervalId = setInterval(() => this.poll(), 1000);If poll() takes 2 seconds to complete but the interval is 1 second, requests will stack up.
private pollingTimeoutId: ReturnType<typeof setTimeout> | null = null;
private isActive: boolean = false;
connect(): void {
this.isActive = true;
this.poll(); // Start first poll immediately
}
disconnect(): void {
this.isActive = false;
this.stopPolling();
}
private stopPolling(): void {
if (this.pollingTimeoutId !== null) {
clearTimeout(this.pollingTimeoutId);
this.pollingTimeoutId = null;
}
}
private scheduleNextPoll(): void {
if (this.isActive) {
this.pollingTimeoutId = setTimeout(() => this.poll(), 1000);
}
}
private async poll(): Promise<void> {
try {
const response = await fetch(this.pollUrlValue, {
headers: { "X-Requested-With": "XMLHttpRequest" },
});
if (response.ok) {
const data = await response.json();
// Process data...
}
} catch {
// Handle error
}
// Schedule next poll only AFTER this one completes
this.scheduleNextPoll();
}- Use
setTimeoutinstead ofsetInterval - Track active state with a boolean (
isActive) to allow clean shutdown - Schedule next poll only after the current one finishes (in the
finallyblock or at the end of the async function) - Clear timeout with
clearTimeoutindisconnect()andstopPolling() - Check
isActivebefore scheduling to prevent orphan timeouts
This pattern ensures:
- No overlapping requests
- Clean shutdown when the controller disconnects
- Resilience to slow network/server responses
- Rules:
.cursor/rules/05-frontend.mdc(TypeScript, Stimulus, quality). - Architecture:
docs/archbook.md— Client-Side Organization, vertical layout. - Stimulus: Stimulus Handbook.
- Symfony UX: Stimulus in Symfony.