Et si vous pouviez transformer votre application Symfony en app mobile native, sans réécrire une seule ligne de code ?
Aujourd’hui, pour proposer une version mobile d’une app web, les options classiques sont lourdes : réécrire en Swift/Kotlin, maintenir une codebase React Native en parallèle, ou se contenter d’un PWA aux possibilités limitées.
Symfony UX Native change la donne. Ce bundle expérimental (introduit dans Symfony UX 2.33) intègre Hotwire Native (par 37signals, les créateurs de Basecamp et HEY) dans l’écosystème Symfony. Le principe : votre app web existante est encapsulée dans un shell natif iOS/Android. L’utilisateur bénéficie de barres de titre natives, de transitions fluides et de gestes natifs, tandis que vous conservez une seule codebase web.
Dans cet article, on va construire ensemble NotePad, un carnet de notes simple avec Symfony. Puis on va le préparer pour le mobile avec UX Native : détection des requêtes natives et configuration des comportements par URL (modale, pull-to-refresh…).
Important : UX Native est marqué comme expérimental. L’API peut changer entre les versions. Ce tutoriel est basé sur Symfony 8.0 et UX Native 2.33+.
Comment ça marche ?
L’architecture Hotwire Native
Hotwire Native fonctionne avec une WebView embarquée dans un shell natif iOS (Swift) ou Android (Kotlin). L’app native charge les pages HTML de votre serveur Symfony et fournit automatiquement :
- Navigation native entre les écrans
- Transitions animées fluides
- Barre de titre native automatique
- Gestes natifs (swipe back, pull-to-refresh…)
Contrairement à React Native ou Flutter, pas de JavaScript framework côté client pour le rendu : c’est votre HTML serveur qui s’affiche. Turbo (déjà dans Symfony UX) gère les navigations sans rechargement complet.
Ce qu’apporte UX Native côté Symfony
Le bundle ajoute deux fonctionnalités côté serveur :
| Fonctionnalité | Description |
|---|---|
| Détection native | Détecte automatiquement si la requête vient d’une app Hotwire Native (via le User-Agent) |
| Configuration des chemins | Définit en PHP comment chaque URL se comporte dans l’app native (modale, push, pull-to-refresh…) |
Note : la majeure partie de ce tutoriel se concentre sur la partie serveur (Symfony). Pour tester la détection native depuis un navigateur, on utilisera la modification du User-Agent dans les DevTools. En bonus (étape 7), on construira un vrai APK Android pour voir le résultat dans un émulateur, sans Android Studio.
Pré-requis
Avant de commencer, assurez-vous d’avoir :
- PHP 8.4+ installé
- Composer installé globalement
- La CLI Symfony installée (symfony.com/download)
- SQLite (extension PHP
pdo_sqlite)
Étape 1 : Créer le projet Symfony
Les étapes 1 à 3 consistent à créer une application web Symfony classique. Si vous avez déjà un projet existant, vous pouvez passer directement à l’étape 4, c’est là que commence la partie UX Native. On construit ici un mini projet “NotePad” volontairement simple pour que la demo reste focalisée sur l’intégration native.
On part d’un projet Symfony vierge avec le pack webapp (qui inclut Twig, Doctrine, Turbo, Stimulus…) :
symfony new notepad --webapp
Entrez dans le projet :
cd notepad
Configurer SQLite
Pour simplifier, on utilise SQLite au lieu de PostgreSQL. Modifiez la variable DATABASE_URL dans le fichier .env :
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
Vérifiez que tout fonctionne :
symfony server:start -d
Ouvrez votre navigateur sur https://127.0.0.1:8000. Vous devriez voir la page d’accueil Symfony par défaut.
Astuce : avec SQLite, aucun serveur de base de données à installer ou configurer.
Étape 2 : Créer l’entité Note
On crée une entité simple avec un titre, un contenu et une date de création :
php bin/console make:entity Note
Répondez aux questions du maker :
title:string,255, not nullablecontent:text, not nullablecreatedAt:datetime_immutable, not nullable
Entité complète (cliquez pour déplier)
<?php
namespace App\Entity;
use App\Repository\NoteRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: NoteRepository::class)]
class Note
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $content = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getContent(): ?string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
}
Créez et exécutez la migration :
php bin/console make:migration
php bin/console doctrine:migrations:migrate -n
Étape 3 : Créer le contrôleur et les templates
Le contrôleur
Créez le fichier src/Controller/NoteController.php :
<?php
namespace App\Controller;
use App\Entity\Note;
use App\Repository\NoteRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/notes')]
class NoteController extends AbstractController
{
#[Route('', name: 'note_index', methods: ['GET'])]
public function index(NoteRepository $repository): Response
{
return $this->render('note/index.html.twig', [
'notes' => $repository->findBy([], ['createdAt' => 'DESC']),
]);
}
#[Route('/new', name: 'note_new', methods: ['GET', 'POST'], priority: 1)]
public function new(Request $request, EntityManagerInterface $em): Response
{
if ($request->isMethod('POST')) {
$note = new Note();
$note->setTitle($request->request->get('title'));
$note->setContent($request->request->get('content'));
$em->persist($note);
$em->flush();
return $this->redirectToRoute('note_index');
}
return $this->render('note/new.html.twig');
}
#[Route('/{id}', name: 'note_show', methods: ['GET'])]
public function show(Note $note): Response
{
return $this->render('note/show.html.twig', [
'note' => $note,
]);
}
}
Note : la route
/notes/newapriority: 1pour être matchée avant/notes/{id}.
Le template de base
Mettez à jour templates/base.html.twig :
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}NotePad{% endblock %}</title>
{% block stylesheets %}{% endblock %}
{% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %}
</head>
<body>
<nav class="navbar">
<a href="{{ path('note_index') }}">NotePad</a>
</nav>
<main class="container">
{% block body %}{% endblock %}
</main>
</body>
</html>
Les templates de notes
Créez le dossier templates/note/ puis les trois fichiers suivants.
templates/note/index.html.twig, liste des notes :
{% extends 'base.html.twig' %}
{% block title %}Mes Notes{% endblock %}
{% block body %}
<h1>Mes Notes</h1>
<a href="{{ path('note_new') }}" class="btn-new">+ Nouvelle note</a>
<div class="notes-list">
{% for note in notes %}
<article class="note-card">
<h2><a href="{{ path('note_show', {id: note.id}) }}">{{ note.title }}</a></h2>
<p>{{ note.content|u.truncate(100) }}</p>
<time datetime="{{ note.createdAt|date('Y-m-d') }}">
{{ note.createdAt|date('d/m/Y à H:i') }}
</time>
</article>
{% else %}
<p class="empty">Aucune note pour le moment. Créez-en une !</p>
{% endfor %}
</div>
{% endblock %}
templates/note/show.html.twig, détail d’une note :
{% extends 'base.html.twig' %}
{% block title %}{{ note.title }}{% endblock %}
{% block body %}
<article class="note-detail">
<h1>{{ note.title }}</h1>
<time datetime="{{ note.createdAt|date('Y-m-d') }}">
{{ note.createdAt|date('d/m/Y à H:i') }}
</time>
<div class="note-content">
{{ note.content|nl2br }}
</div>
</article>
<a href="{{ path('note_index') }}" class="back-link">← Retour aux notes</a>
{% endblock %}
templates/note/new.html.twig, formulaire de création :
{% extends 'base.html.twig' %}
{% block title %}Nouvelle Note{% endblock %}
{% block body %}
<h1>Nouvelle Note</h1>
<form method="POST" action="{{ path('note_new') }}" class="note-form">
<div class="form-group">
<label for="title">Titre</label>
<input type="text" id="title" name="title" required>
</div>
<div class="form-group">
<label for="content">Contenu</label>
<textarea id="content" name="content" rows="8" required></textarea>
</div>
<button type="submit" class="btn-submit">Enregistrer</button>
</form>
<a href="{{ path('note_index') }}" class="back-link">← Annuler</a>
{% endblock %}
Installer twig/string-extra
Le filtre |u.truncate() utilisé dans le template de liste nécessite un package supplémentaire, non inclus par défaut :
composer require twig/string-extra
Un peu de style
Remplacez le contenu de assets/styles/app.css :
Code CSS complet (cliquez pour déplier)
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #f5f5f7;
color: #1d1d1f;
line-height: 1.6;
}
.navbar {
background: #1d1d1f;
padding: 1rem 2rem;
}
.navbar a {
color: #fff;
text-decoration: none;
font-size: 1.2rem;
font-weight: 600;
}
.container {
max-width: 700px;
margin: 2rem auto;
padding: 0 1rem;
}
h1 {
margin-bottom: 1rem;
}
.btn-new {
display: inline-block;
background: #0071e3;
color: #fff;
padding: 0.6rem 1.2rem;
border-radius: 8px;
text-decoration: none;
margin-bottom: 1.5rem;
}
.note-card {
background: #fff;
border-radius: 12px;
padding: 1.2rem;
margin-bottom: 1rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.note-card h2 { font-size: 1.1rem; margin-bottom: 0.4rem; }
.note-card h2 a { color: #1d1d1f; text-decoration: none; }
.note-card p { color: #6e6e73; font-size: 0.95rem; }
.note-card time { color: #86868b; font-size: 0.8rem; }
.note-detail time { display: block; color: #86868b; margin-bottom: 1.5rem; }
.note-content { font-size: 1.05rem; line-height: 1.8; }
.form-group { margin-bottom: 1.2rem; }
.form-group label { display: block; font-weight: 600; margin-bottom: 0.3rem; }
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.7rem;
border: 1px solid #d2d2d7;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
}
.btn-submit {
background: #0071e3;
color: #fff;
border: none;
padding: 0.7rem 2rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
}
.back-link {
display: inline-block;
margin-top: 1.5rem;
color: #0071e3;
text-decoration: none;
}
.empty { color: #86868b; font-style: italic; }
Lancez le serveur et naviguez sur https://127.0.0.1:8000/notes. Créez quelques notes pour avoir des données de test.
Étape 4 : Installer UX Native
À ce stade, on a une application Symfony classique qui fonctionne dans un navigateur. NotePad ne sait rien du mobile. C’est maintenant qu’on va la préparer pour qu’elle puisse vivre dans une app native, sans toucher au code métier.
Le rôle de UX Native n’est pas de transformer votre app en app mobile. C’est de fournir les outils côté serveur pour que votre app web coopère intelligemment avec un shell natif Hotwire Native. Concrètement, le bundle va nous permettre de :
- Détecter si la requête vient d’une app native ou d’un navigateur (étape 5)
- Générer un fichier JSON qui dit à l’app native comment se comporter sur chaque URL (étape 6)
Installez le bundle :
composer require symfony/ux-native
Avec AssetMapper (inclus dans le pack
--webapp), aucune étape supplémentaire côté JavaScript n’est nécessaire. Si vous utilisez Webpack Encore, lanceznpm install --force && npm run watch.
Étape 5 : Détecter les requêtes natives
Pourquoi détecter ?
Quand votre app tourne dans un shell natif, certains éléments de l’interface web deviennent redondants. Par exemple, l’app native a sa propre barre de navigation avec un titre et un bouton retour. Afficher en plus votre <nav> HTML ferait doublon. À l’inverse, certains éléments n’ont de sens que dans le contexte natif : un bouton “Partager” qui déclenche la feuille de partage iOS/Android n’a pas d’utilité dans un navigateur classique.
La détection native permet de servir un HTML adapté au contexte : le même contrôleur, le même template, mais avec des blocs conditionnels.
Comment ça marche ?
UX Native fournit une fonction Twig ux_is_native() qui détecte automatiquement si la requête vient d’une app Hotwire Native. Sous le capot, le bundle enregistre un NativeListener qui inspecte le header User-Agent : si celui-ci contient la chaîne Hotwire Native (envoyée automatiquement par tous les clients Hotwire Native), il ajoute un attribut _hotwire_native à la requête.
Adapter le layout
Modifiez templates/base.html.twig pour masquer la navbar web quand l’utilisateur est dans l’app native (l’app native a sa propre barre de navigation) :
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}NotePad{% endblock %}</title>
{% block stylesheets %}{% endblock %}
{% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %}
</head>
<body>
{% if not ux_is_native() %}
<nav class="navbar">
<a href="{{ path('note_index') }}">NotePad</a>
</nav>
{% endif %}
<main class="container">
{% block body %}{% endblock %}
</main>
</body>
</html>
C’est aussi simple que ça. Un {% if %} et votre UI s’adapte au contexte.
Tester sans app mobile
Pas besoin d’un iPhone pour tester ! Ouvrez les DevTools de votre navigateur (F12), puis :
- Ouvrez le panneau Network Conditions (Chrome) ou Settings > User Agent (Firefox)
- Désactivez “Use browser default” et ajoutez
Hotwire Nativeau User-Agent - Rechargez la page
La navbar devrait disparaître. Remettez le User-Agent par défaut : elle réapparaît.
Étape 6 : Configurer les chemins natifs
Le concept clé : la Path Configuration
C’est sans doute la partie la plus importante à comprendre. Quand une app Hotwire Native démarre, elle télécharge un fichier JSON depuis votre serveur. Ce fichier est un contrat entre votre back-end et l’app mobile : il décrit comment chaque URL doit se comporter dans le contexte natif.
Par exemple, ce JSON peut dire :
- “Toutes les pages s’affichent normalement avec pull-to-refresh activé”
- “La page
/notes/newdoit s’ouvrir en modale (une feuille qui glisse depuis le bas)” - “La page
/settingsdoit remplacer l’écran courant sans animation”
Sans ce fichier, l’app native affiche toutes les pages de la même façon. Avec, vous pilotez l’expérience native depuis votre serveur PHP, sans recompiler l’app mobile.
Le gros avantage : vous pouvez modifier le comportement de l’app native sans publier de mise à jour sur le Play Store ou l’App Store. Il suffit de changer le JSON côté serveur.
Écrire la config en PHP plutôt qu’en JSON
Plutôt que de maintenir un fichier JSON à la main, UX Native permet de définir cette configuration en PHP avec des attributs. Le bundle se charge de générer le JSON. Vous bénéficiez de l’autocomplétion, du typage, et vous pouvez versionner vos configs proprement.
Créer le provider de configuration
Créez le fichier src/Native/AppNativeConfiguration.php :
<?php
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(
settings: [
'tabs' => [
['title' => 'Notes', 'url' => '/notes', 'icon' => 'note.text'],
],
],
rules: [
// Règle par défaut : navigation classique avec pull-to-refresh
new Rule(
patterns: ['.*'],
properties: [
'context' => 'default',
'pull_to_refresh_enabled' => true,
],
),
// Le formulaire de création s'ouvre en modale
new Rule(
patterns: ['/notes/new'],
properties: [
'context' => 'modal',
'modal_style' => 'large',
'pull_to_refresh_enabled' => false,
],
),
],
);
}
#[AsNativeConfiguration('/config/android_v1.json')]
public function androidV1(): Configuration
{
return new Configuration(
settings: [],
rules: [
// Règle par défaut
new Rule(
patterns: ['.*'],
properties: [
'context' => 'default',
'pull_to_refresh_enabled' => true,
'uri' => 'hotwire://fragment/web',
],
),
// Modale pour le formulaire de création
new Rule(
patterns: ['/notes/new'],
properties: [
'context' => 'modal',
'pull_to_refresh_enabled' => false,
'uri' => 'hotwire://fragment/web',
],
),
],
);
}
}
Décortiquons ce code :
#[AsNativeConfigurationProvider]marque la classe comme fournisseur de configurations natives. Le bundle la détecte automatiquement grâce à l’autoconfiguration Symfony.#[AsNativeConfiguration('/config/ios_v1.json')]associe la méthode au chemin JSON/config/ios_v1.json. C’est cette URL que l’app native appellera au démarrage pour récupérer sa configuration.Configurationcontient dessettings(paramètres globaux envoyés à l’app) et desrules(règles ordonnées qui dictent le comportement par URL).Ruleassocie despatterns(regex matchant les URLs visitées) à desproperties(comportements natifs). Par exemple, le pattern.*matche toutes les URLs, tandis que/notes/newne matche que le formulaire de création.- Les règles sont appliquées séquentiellement : les dernières écrasent les premières. C’est pour ça qu’on met la règle générique (
.*) en premier et les exceptions après. uri(Android uniquement) : indique quel type de fragment Android utiliser.hotwire://fragment/webest le fragment WebView par défaut fourni par Hotwire Native. C’est la propriétécontext(et non l’URI) qui détermine si la page s’ouvre en navigation normale ou en modale.
Pourquoi deux configs (iOS et Android) ? Parce que les propriétés disponibles diffèrent selon la plateforme. iOS a
modal_stylepour contrôler l’apparence de la modale, Android auriqui est obligatoire. En les séparant, chaque plateforme reçoit exactement ce dont elle a besoin.
Comprendre les propriétés
Voici les propriétés disponibles pour contrôler le comportement natif :
| Propriété | Valeurs possibles | Défaut | Effet |
|---|---|---|---|
context | default, modal | default | Navigation standard ou ouverture en modale |
presentation | push, pop, replace, replace_root, clear_all, refresh, none | default | Type de transition entre écrans |
pull_to_refresh_enabled | true, false | iOS: true, Android: false | Active le “tirer pour rafraîchir” |
animated | true, false | true | Active/désactive les animations de transition |
modal_style (iOS uniquement) | large, medium, full, page_sheet, form_sheet | - | Style visuel de la modale |
uri (Android, requis) | hotwire://... | - | URI de destination pour Android |
Générer et vérifier les fichiers JSON
Pour que l’app native puisse récupérer cette configuration, il faut générer les fichiers JSON dans le dossier public/ :
php bin/console ux-native:dump
Cette commande transforme vos objets PHP Configuration / Rule en fichiers JSON statiques : public/config/ios_v1.json et public/config/android_v1.json. Le serveur web les sert directement, sans passer par le kernel Symfony.
Vérifiez le résultat en accédant à https://127.0.0.1:8000/config/ios_v1.json :
{
"settings": {
"tabs": [
{
"title": "Notes",
"url": "/notes",
"icon": "note.text"
}
]
},
"rules": [
{
"patterns": [".*"],
"properties": {
"context": "default",
"pull_to_refresh_enabled": true
}
},
{
"patterns": ["/notes/new"],
"properties": {
"context": "modal",
"modal_style": "large",
"pull_to_refresh_enabled": false
}
}
]
}
C’est exactement le contrat entre votre serveur Symfony et l’app native. Quand l’app iOS démarre, elle fait un GET /config/ios_v1.json, parse ce JSON, et sait que :
- Par défaut, chaque page s’affiche en navigation classique avec pull-to-refresh
- Sauf
/notes/new, qui s’ouvre en modale style “large” (une feuille qui couvre la majorité de l’écran)
Toute cette logique est pilotée depuis PHP. Si demain vous voulez que /notes/{id}/edit s’ouvre aussi en modale, il suffit d’ajouter une Rule dans votre provider, sans toucher au code de l’app mobile.
Important : pensez à relancer cette commande à chaque modification de votre
AppNativeConfiguration.php, et ajoutez-la à votre pipeline de déploiement (juste aprèscache:clear).
Récapitulatif
Voici tout ce qu’on a mis en place :
| Composant | Rôle |
|---|---|
symfony/ux-native | Bundle d’intégration Hotwire Native pour Symfony |
ux_is_native() | Fonction Twig, détecte si la requête vient d’une app native |
#[AsNativeConfigurationProvider] | Attribut, déclare une classe comme fournisseur de config |
#[AsNativeConfiguration('/path.json')] | Attribut, mappe une méthode à un fichier JSON |
Configuration + Rule | Objets PHP, définissent les règles de comportement natif |
ux-native:dump | Commande, génère les fichiers JSON statiques dans public/ |
| Fichier créé | Rôle |
|---|---|
src/Native/AppNativeConfiguration.php | Configurations des chemins iOS et Android |
public/config/ios_v1.json | Configuration iOS (générée par ux-native:dump) |
public/config/android_v1.json | Configuration Android (générée par ux-native:dump) |
Étape 7 (Bonus) : Builder un APK Android
On a préparé le côté serveur. Mais à quoi ça ressemble concrètement dans une app native ? Bonne nouvelle : le shell Android se résume à 3 fichiers Kotlin/XML et une vingtaine de lignes de code.
Voici ce que fait le shell Android, en résumé :
| Fichier | Rôle | Équivalent Symfony |
|---|---|---|
AndroidManifest.xml | Déclare l’app et ses permissions | config/services.yaml |
MainActivity.kt | Point d’entrée, indique l’URL de départ | Un contrôleur avec une route |
NotepadApplication.kt | Charge le JSON de path configuration | Consomme ce que ux-native:dump a généré |
activity_main.xml | Layout avec la WebView Hotwire | Le “template” de l’app |
| Fichiers Gradle | Dépendances et build | composer.json |
Vous n’avez pas besoin de comprendre le Kotlin en détail, le code est volontairement minimal et les commentaires expliquent chaque ligne.
Pré-requis
Pas besoin d’Android Studio (15 Go). Les command-line tools suffisent (~2 Go) :
# Installer les outils (macOS)
brew install --cask android-commandlinetools
brew install --cask temurin # Java (nécessite sudo)
brew install gradle
# Installer le SDK Android
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
yes | sdkmanager --sdk_root="$ANDROID_HOME" \
"platform-tools" \
"platforms;android-35" \
"build-tools;34.0.0" \
"emulator" \
"system-images;android-34;google_apis;arm64-v8a"
# Créer un émulateur
echo "no" | avdmanager create avd -n notepad_test \
-k "system-images;android-34;google_apis;arm64-v8a" --force
Structure du projet Android
Créez un dossier android-shell/ à la racine de votre projet Symfony :
android-shell/
├── build.gradle.kts
├── settings.gradle.kts
├── gradle.properties
└── app/
├── build.gradle.kts
└── src/main/
├── AndroidManifest.xml
├── java/com/notepad/
│ ├── MainActivity.kt
│ └── NotepadApplication.kt
└── res/
├── layout/activity_main.xml
└── values/styles.xml
Les fichiers Gradle
Gradle est l’outil de build du monde Android, l’équivalent de Composer pour PHP. Il gère les dépendances (ici Hotwire Native, AndroidX…), compile le code Kotlin, et produit l’APK installable. Les fichiers .gradle.kts sont ses fichiers de configuration, écrits en Kotlin.
settings.gradle.kts :
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "NotePad"
include(":app")
build.gradle.kts (racine) :
plugins {
id("com.android.application") version "8.8.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
}
gradle.properties :
android.useAndroidX=true
app/build.gradle.kts :
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.notepad"
compileSdk = 35
defaultConfig {
applicationId = "com.notepad"
minSdk = 28
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("dev.hotwire:core:1.2.0")
implementation("dev.hotwire:navigation-fragments:1.2.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.12.0")
}
Les deux dépendances clés sont dev.hotwire:core (le moteur Hotwire Native) et dev.hotwire:navigation-fragments (la navigation automatique entre écrans).
Le code Kotlin (3 fichiers)
AndroidManifest.xml :
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".NotepadApplication"
android:allowBackup="true"
android:label="NotePad"
android:theme="@style/Theme.NotePad"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Note :
usesCleartextTraffic="true"autorise le HTTP non chiffré, nécessaire pour le développement local. En production, vous utiliserez HTTPS.
MainActivity.kt, le coeur de l’app, 10 lignes utiles :
package com.notepad
import android.os.Bundle
import dev.hotwire.navigation.activities.HotwireActivity
import dev.hotwire.navigation.navigator.NavigatorConfiguration
class MainActivity : HotwireActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
override fun navigatorConfigurations() = listOf(
NavigatorConfiguration(
name = "main",
startLocation = "http://10.0.2.2:8000/notes",
navigatorHostId = R.id.main_nav_host
)
)
}
Deux choses à noter :
10.0.2.2est l’alias Android pour127.0.0.1de la machine hôte (votre Mac). En production, remplacez par l’URL de votre serveur.navigatorConfigurations()est la seule méthode abstraite à implémenter : elle indique où démarrer et quel conteneur de navigation utiliser.
NotepadApplication.kt, charge la path configuration depuis votre serveur Symfony :
package com.notepad
import android.app.Application
import dev.hotwire.core.config.Hotwire
import dev.hotwire.core.turbo.config.PathConfiguration
class NotepadApplication : Application() {
override fun onCreate() {
super.onCreate()
Hotwire.loadPathConfiguration(
context = this,
location = PathConfiguration.Location(
remoteFileUrl = "http://10.0.2.2:8000/config/android_v1.json"
)
)
}
}
C’est ici que le lien se fait avec votre configuration Symfony (étape 6). L’app télécharge le JSON généré par ux-native:dump et l’utilise pour savoir comment se comporter sur chaque URL.
Le layout et le style
res/layout/activity_main.xml :
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/main_nav_host"
android:name="dev.hotwire.navigation.navigator.NavigatorHost"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="false" />
Ce layout contient un seul élément : le NavigatorHost de Hotwire Native, qui gère automatiquement la WebView et la navigation entre écrans.
res/values/styles.xml :
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.NotePad" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">#0071E3</item>
<item name="colorPrimaryVariant">#0062CC</item>
<item name="colorOnPrimary">#FFFFFF</item>
</style>
</resources>
Builder et lancer
Avant tout, lancez votre serveur Symfony sans HTTPS (la WebView Android ne fait pas confiance aux certificats auto-signés de développement) :
symfony server:start -d --no-tls
Puis buildez et lancez l’app Android :
# Générer le Gradle wrapper
cd android-shell
gradle wrapper --gradle-version 8.12.1
# Builder l'APK (utiliser Java 17 pour la compatibilité)
export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
./gradlew assembleDebug
L’APK est généré dans app/build/outputs/apk/debug/app-debug.apk.
# Lancer l'émulateur (dans un terminal séparé)
$ANDROID_HOME/emulator/emulator -avd notepad_test
# Installer et lancer l'app
$ANDROID_HOME/platform-tools/adb install app/build/outputs/apk/debug/app-debug.apk
$ANDROID_HOME/platform-tools/adb shell am start -n com.notepad/.MainActivity
Votre app NotePad s’ouvre dans l’émulateur Android avec :
- Barre de titre Android native (plus de navbar HTML)
- Navigation native entre les écrans (transitions animées)
- Pull-to-refresh sur la liste des notes
- Formulaire en modale quand vous cliquez sur ”+ Nouvelle note”
- Swipe back pour revenir en arrière
Le tout en chargeant le HTML de votre serveur Symfony. Zéro réécriture de votre code PHP/Twig.
Et pour iOS ?
Le principe est identique. Voici un aperçu du code Swift minimal (non testé dans ce tutoriel) utilisant le SDK Hotwire Native iOS. Le shell iOS se résume aussi à quelques dizaines de lignes :
import HotwireNative
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let navigator = Navigator()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: windowScene)
// Charge la path configuration depuis le serveur Symfony
Hotwire.loadPathConfiguration(from: [
.server(URL(string: "https://votre-serveur.com/config/ios_v1.json")!)
])
// Démarre sur la liste des notes
navigator.route(URL(string: "https://votre-serveur.com/notes")!)
window?.rootViewController = navigator.rootViewController
window?.makeKeyAndVisible()
}
}
Note : builder une app iOS nécessite un Mac avec Xcode installé (gratuit, ~25 Go). Le simulateur iOS est inclus dans Xcode. Un compte Apple Developer payant (99$/an) n’est nécessaire que pour publier sur l’App Store.
Pour aller plus loin
Quelques pistes pour enrichir votre app :
- Versioning des configs : créez plusieurs configurations (
ios_v1,ios_v2…) pour gérer les mises à jour de l’app native sans casser les anciennes versions en circulation - Settings dynamiques : utilisez la section
settingspour passer des feature flags ou des URLs de services à l’app native - Bridge avancé : explorez
BridgeComponentpour accéder à d’autres APIs natives (caméra, notifications push, biométrie, haptics…) - Service tags : pour les cas complexes, vous pouvez aussi définir vos configurations via des tags de service YAML au lieu des attributs PHP
Conclusion
Avec Symfony UX Native, transformer une app web Symfony en application mobile native devient accessible :
- Zéro réécriture : votre code Symfony existant sert de base, votre HTML est rendu directement dans l’app native
- Adaptation intelligente :
ux_is_native()ajuste l’UI au contexte (web vs. native) en une ligne - Configuration déclarative : les comportements natifs (modale, pull-to-refresh, transitions) se définissent en PHP avec des attributs
Le tout avec un bundle expérimental mais prometteur, porté par la philosophie Hotwire : une seule codebase web, une expérience native.
Ressources :
Loading comments...