Stimulus is a modest JavaScript framework designed to work with your HTML rather than replace it. In Symfony projects, it fits naturally because it encourages server-rendered pages while adding just enough JavaScript behavior where needed. Instead of building large client-side applications, Stimulus focuses on enhancing existing markup with small, reusable behaviors.
In essence, Stimulus binds JavaScript classes directly to HTML using data-* attributes. When you add data-controller="my-identifier" to an element, Stimulus looks for a corresponding controller file and connects the two automatically. This makes the relationship between HTML and JavaScript explicit and easy to reason about, especially in Symfony apps where templates drive most of the UI.
How Controllers Are Discovered
When Stimulus encounters an element with a data-controller attribute, it checks whether a matching controller file exists in your JavaScript assets directory. For example, if your HTML contains data-controller="my-identifier", Stimulus will look for a file named my_identifier_controller.js and instantiate the Controller class defined inside it.
This convention-based approach removes configuration overhead. As long as your naming is correct and the controller is registered by Symfonyโs asset system, Stimulus handles the rest. You do not manually attach listeners or initialize componentsโStimulus does that automatically as the page loads.
Basic Controller Example
When you define a controller in HTML like this:
<div data-controller="my-identifier">
Hello World
</div>Stimulus will check for an existing file named:
assets/controllers/my_identifier_controller.js// assets/controllers/my_identifier_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
connect() {
console.log("Controller connected");
}
}If it does exits when the page loads, Stimulus instantiates the Controller class inside that file, automatically connecting it to the element.
Naming Conventions Matter
Stimulus relies heavily on naming conventions, and understanding them early prevents subtle bugs. In HTML, controller identifiers must use kebab-case, such as simple-item. This keeps markup readable and consistent with HTML attribute standards.
On the JavaScript side, the corresponding controller file must use snake_case and end with _controller.js. Using the earlier example, data-controller="simple-item" maps to simple_item_controller.js. Stimulus performs this transformation internally, so matching these conventions is essential for proper initialization.
Automatic Initialization and Dynamic Content
One of Stimulusโs most powerful features is that it continuously observes the DOM. This means it does not stop working after the initial page load. If you inject new HTML into the page using Ajax, Turbo, or any other method, and that HTML contains a data-controller attribute, Stimulus will detect it and initialize the controller automatically.
For example, if an Ajax response inserts an element with data-controller="ajax-element", Stimulus will immediately instantiate ajax_element_controller.js without any extra code. This behavior makes Stimulus especially useful in Symfony applications that rely on partial page updates.
Accessing the Bound Element
Inside a controller class, Stimulus provides direct access to the element that owns the controller through this.element. This refers to the exact DOM node that contains the data-controller attribute. It allows you to scope behavior cleanly without querying the entire document or relying on fragile selectors.
Example:
export default class extends Controller {
connect() {
console.log(this.element);
}
}This approach encourages encapsulation. Each controller only manages its own section of the DOM, making your JavaScript easier to maintain and reason about as your application grows.
Using Multiple Controllers on One Element
Stimulus allows multiple controllers to be attached to a single element. You simply separate controller identifiers with spaces inside the data-controller attribute. For example, data-controller="earth ocean-body atmosphere" will initialize three separate controllers on the same element.
Each identifier maps to its own controller file:
earth_controller.js
ocean_body_controller.js
atmosphere_controller.jsThis pattern works well when different behaviors should remain independent but still operate on the same DOM element.
Nested Controllers
Another common and powerful pattern is nesting controllers. A parent element can have its own controller while child elements define additional controllers inside it. Each controller operates independently, even though they exist within the same DOM tree.
Example:
<div data-controller="parent-item">
<p>Hi this is a container</p>
<div data-controller="child-item">
This is a child controller
</div>
</div>In a structure where a parent container has data-controller="parent-item" and a child element has data-controller="child-item", Stimulus will initialize both
parent_item_controller.js
child_item_controller.jsThis allows you to build layered behavior without tightly coupling logic between components.
Actions and Targets: The Core Interaction Model
Stimulus controllers revolve around two main concepts:
- Actions: This define how events trigger controller methods.
- Targets: This define how elements are referenced inside a controller. Together, they form the foundation of how Stimulus interacts with the DOM.
An action is declared using the data-action attribute. For example, data-action="click->child-item#method" means that when the element is clicked, the method() function in the child-item controller will be called. The pattern is always event, controller identifier, and method name.
Example:
<button data-action="click->child-item#myMethod">
Click Me
</button>export default class extends Controller {
myMethod() {
alert("Button clicked");
}
}Action pattern are defined as:
data-action="event->identifier#methodName"Targets are declared using data-[identifier]-target="name". This marks an element as important to a specific controller.
Example:
<div data-controller="child-item">
<span data-child-item-target="name">John</span>
</div>However, declaring a target in HTML alone does nothing until the controller explicitly defines it.
Working with Targets in Controllers
To activate targets, you must declare them using static targets = ["name"] inside the controller class.
Example:
export default class extends Controller {
static targets = ["name"];
connect() {
console.log(this.nameTarget.textContent);
}
}Once defined, Stimulus automatically generates helpful properties for you. It creates:
hasNameTargetto check if the target existsnameTargetto access the first matching element, andnameTargetsto access all matching elements as an array.
This system eliminates repetitive DOM queries and makes controller logic more expressive. Instead of searching the document manually, you work with well-defined references that are scoped to the controllerโs element.
Usage Example:
connect() {
if (this.hasNameTarget) {
this.nameTarget.style.color = "blue";
}
}Why Stimulus Fits Symfony So Well
Stimulus aligns closely with Symfonyโs philosophy of clarity and structure. It does not fight server-rendered HTML, and it avoids turning every interaction into a JavaScript-heavy workflow. By relying on conventions and small controllers, it keeps frontend behavior predictable and easy to debug.
For developers building Symfony applications that need interactivity without complexity, Stimulus provides a clean, long-term solution that scales naturally as features grow.