setono/html-element-bundle
A Symfony bundle for building HTML elements as reusable PHP objects instead of loose markup. Compose attributes and child nodes, then render clean, testable HTML from services or Twig, keeping templates maintainable and markup consistent.
1. Install and confirm the bundle is wired up
composer require setono/html-element-bundle
Flex should register it in config/bundles.php. If it doesn't (the package is tiny, so the recipe may be thin or missing), add it manually:
Setono\HtmlElementBundle\SetonoHtmlElementBundle::class => ['all' => true],
2. Look at these three places before writing any code
README.md — for a 1-star package this is the documentation. Read it end to end; there is no blog-post ecosystem to fall back on.src/DependencyInjection/Configuration.php — the full config tree. Dump it directly:
bin/console config:dump-reference setono_html_element
src/Twig/ — this tells you the actual public API (function, tag, or filter) and its argument order. Confirm what's registered:
bin/console debug:twig | grep -i element
3. First use case: one small, self-contained element
Don't start with your design system. Pick a single leaf component that has behaviour and no dependencies — a copy-to-clipboard button, a character counter, a disclosure/toggle. That exercises the whole loop (class → template → registration → Twig usage → browser behaviour) in under an hour and tells you whether the package's abstractions fit your project.
The shape you're aiming for is roughly: a PHP class (or a template + registration entry) describing the element, a Twig template that renders its markup, a JS file that calls customElements.define(...), and a Twig-side call site. Confirm the exact class names and registration mechanism from the README before assuming — the package is small enough that the API surface is intentionally narrow, and guessing wastes more time than reading.
4. Sanity-check the render before touching JavaScript
Render the element in a scratch template and look at the HTML source (View Source, not the inspector — you want pre-JS markup). If the custom element tag isn't in the source, the problem is Twig-side, not browser-side. Splitting the debugging that way saves a lot of time on a package with no community answers.
One element, one directory
Keep the PHP class/template/JS co-located so the element is movable and deletable as a unit:
templates/components/copy-button.html.twig
assets/components/copy-button.js
src/HtmlElement/CopyButton.php
This matters more here than in a mature package: because there are zero dependents, there's no convention to inherit. Establish your own and write it in the repo README.
Server-render first, then upgrade
The most robust pattern with this bundle is to let Twig render complete, meaningful HTML, and treat the custom element as a progressive enhancement. The element works without JS; the JS only adds behaviour.
<copy-button data-target="#install-command">
<button type="button" class="btn">Copy</button>
</copy-button>
class CopyButton extends HTMLElement {
connectedCallback() {
if (this.dataset.initialized) return; // guard against re-connect
this.dataset.initialized = 'true';
this.querySelector('button')?.addEventListener('click', () => {
navigator.clipboard.writeText(this.dataset.target ? document.querySelector(this.dataset.target).textContent : '');
});
}
}
customElements.define('copy-button', CopyButton);
This keeps you safe if the package's asset-loading integration doesn't do what you expect, and it survives Turbo/htmx navigation (which replaces DOM nodes and re-fires connectedCallback).
Configuration over inline attributes
If the element takes structured data (a list of options, a config object), serialize it once via a data-* attribute with json_encode, and parse it in connectedCallback. Avoid scattering many attributes — it makes the Twig call site unreadable and makes the element hard to test in isolation.
Asset delivery: pick one path and commit
Two viable integrations:
assets/, import it from assets/app.js, and let AssetMapper handle versioning. Simplest, and pairs naturally with a small bundle like this.Don't rely on the bundle to auto-inject assets unless the README explicitly promises it. Explicitly including your element JS from base.html.twig is one line and removes an entire class of failure.
Compose elements from elements
Once two elements exist, use them inside each other's templates. This is where a custom-element approach pays off compared to Twig includes: each element owns its behaviour, and nesting is just HTML. Keep parent/child communication through DOM events (this.dispatchEvent(new CustomEvent('copy-button:copied', { bubbles: true }))) rather than reaching across the DOM — it keeps elements independently testable.
Testing
assertSelectorExists('copy-button[data-target]').customElements.define in the test, and assert on connectedCallback behaviour. Keep the class exported so it's importable; don't put it inline in a <script> tag.Where it fits vs. Symfony UX TwigComponent
Before adopting broadly, compare against native symfony/ux-twig-component (<twig:CopyButton />). The bundle's advantage is real custom elements — shadow DOM, slots, framework-agnostic reuse of the same markup outside Symfony. If you only need reusable Twig markup with props and no browser-side lifecycle, the native component is better supported and you should use it instead.
Custom element names must contain a hyphen. customElements.define('copybutton', …) throws; copy-button is required. If your element silently does nothing, this is the first thing to check in the console.
Registration order and FOUC. If your JS loads after the element is in the DOM, the browser upgrades it on define() — usually fine — but any styling keyed to :defined will flash. Load the element scripts in <head> with defer, or accept the upgrade flash deliberately. Do not assume the bundle enforces an order for you.
Guard connectedCallback against repeat invocation. DOM moves (Turbo, htmx, live-reload dev servers) detach and reattach nodes, firing connectedCallback again. Without a guard you get doubled event listeners and doubled network calls. Use disconnectedCallback to clean up anything you attach outside the element.
Don't |raw your way out of escaping. Attribute values passed from Twig are autoescaped. If you find yourself adding |raw to make an attribute render, the real problem is that you're injecting a data structure through a string attribute — encode it with json_encode(..., JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) inside a data- attribute instead. This is also your XSS boundary; treat element attributes as untrusted user input.
Shadow DOM cuts both ways. Styles don't leak in, and your global design tokens/CSS reset don't reach inside. If the element uses shadow DOM, copy CSS custom properties across the boundary (:host inherits custom properties, so a token-based design system works) and don't expect utility classes to function inside.
Stale Twig cache in prod. Template changes to elements are not hot-reloaded in production; bin/console cache:clear after deploys or after editing element templates. This bites people who test element edits on a production-like environment and conclude the package is broken.
Template lookup is a common mystery. If the bundle resolves element templates by convention from a configurable path, check the config for that path after moving files or changing the template namespace. A silent fallback to a default template is a classic symptom.
Low-adoption risk — plan for it. One star, zero dependents, one maintainer. The last release (2026-05-01) says it's alive, but don't expect Stack Overflow answers or third-party tutorials. Mitigations:
"setono/html-element-bundle": "^x.y") and commit your lockfile.customElements.define or Symfony UX is a mechanical change, not a rewrite.Debugging checklist, in order:
bin/console debug:twig — is the function/tag actually registered? (Wrong bundle registration.)customElements.get('my-element') returns undefined? (JS never loaded, or name lacks a hyphen.):not(:defined) elements everywhere? (JS 404'd; check network tab and AssetMapper/Encore manifest.)document.querySelector('my-element').dataset in the console. Twig attribute-name casing differences (data-fooBar vs data-foo-bar) are a frequent silent mismatch with dataset.fooBar.Extension points worth auditing. Look for service tags (debug:container --tag=<something>) and any AbstractHtmlElement-style base class in the source. Registering elements via a service tag is usually the cleanest integration — your elements get autowired dependencies (router, translator, security) rather than being pure templates. If the package offers that, use it; if it only offers template conventions, keep element logic out of the class and accept that it's markup plumbing.
How can I help you explore Laravel packages today?