Hotwire Native Integration
Go Native with your Symfony app
Wrap your Symfony web application in a native mobile shell with Hotwire Native. Detect native requests, conditionally render content, and generate JSON configurations for your mobile clients.
composer require symfony/ux-native
Without UX Native
Browser navigation bar, web navbar — it feels like a website.
With UX Native
Native title bar, no browser chrome — it feels like an app.
One codebase, two experiences
No need to duplicate templates. The ux_is_native() Twig function lets you adapt your existing Twig templates for native clients. For example:
- hide the navbar,
- remove the footer,
- or show mobile-specific content, all from the same code.
{# Navbar only visible in the web browser #}
{% if not ux_is_native() %}
<nav class="navbar">
<a href="/">TaskFlow</a>
</nav>
{% endif %}
<h1>My Tasks</h1>
<!-- ... -->
JSON configurations, the Symfony way
Use PHP attributes AsNativeConfigurationProvider and AsNativeConfiguration to declare your native configurations: path rules, settings, per-platform overrides.
UX Native serves them dynamically during development and can dump them as static JSON files for production deployment.
namespace App\Native;
use Symfony\UX\Native\Attribute\AsNativeConfiguration;
use Symfony\UX\Native\Attribute\AsNativeConfigurationProvider;
use Symfony\UX\Native\Configuration\Configuration;
use Symfony\UX\Native\Configuration\Rule;
#[AsNativeConfigurationProvider]
final class AppNativeConfiguration
{
#[AsNativeConfiguration('/config/ios_v1.json')]
public function iosV1(): Configuration
{
return new Configuration(
rules: [
new Rule(
patterns: ['.*'],
properties: [
'context' => 'default',
'pull_to_refresh_enabled' => true,
],
),
],
);
}
}
Bridge Components
Bridge components let you progressively enhance specific web elements with native UI (like replacing a web link with a native navigation bar button), and without changing your server-side code.
Three layers, one component
A bridge component is made of three parts: an HTML element with data-controller, a Stimulus controller extending BridgeComponent, and a matching Swift class.
The web and native sides are linked by a shared component name: button in this example.
<a href="/profile"
{{ stimulus_controller('button') }}
data-bridge-title="Profile"
>
View profile
</a>
Stimulus sends, native handles
The Stimulus controller calls this.send("connect", {/* ... */}, callback) to pass data to the native side.
When the native button is tapped, it replies and the callback fires: clicking the original web element.
import {BridgeComponent} from "@hotwired/hotwire-native-bridge"
export default class extends BridgeComponent {
static component = "button"
connect() {
super.connect()
const element = this.bridgeElement
const title = element.bridgeAttribute("title")
this.send("connect", {title}, () => {
this.element.click()
})
}
}
Native code, same component name
The Swift component overrides name to match the Stimulus identifier button.
When a message arrives, it builds a UIBarButtonItem and places it in the navigation bar.
Tapping it replies to the Stimulus controller, which clicks the original link.
Register it one in your AppDelegate.swift:
Hotwire.registerBridgeComponents([
ButtonComponent.self
])
import HotwireNative
import UIKit
final class ButtonComponent: BridgeComponent {
override class var name: String { "button" }
override func onReceive(message: Message) {
guard let viewController else { return }
addButton(via: message, to: viewController)
}
private func addButton(via message: Message,
to viewController: UIViewController) {
guard let data: MessageData = message.data() else { return }
let action = UIAction { [unowned self] _ in
self.reply(to: "connect")
}
let item = UIBarButtonItem(title: data.title, primaryAction: action)
viewController.navigationItem.rightBarButtonItem = item
}
}
private extension ButtonComponent {
struct MessageData: Decodable {
let title: String
}
}
Hide web elements in native apps
When a native button is displayed in the navigation bar, the original web link can be hidden.
This CSS selector targets elements marked as supported bridge components, preventing duplicate UI from appearing in the native app.
[data-bridge-components~="button"]
[data-controller~="button"] {
display: none;
}
Install It
$ composer require symfony/ux-native