20 min de lecture

Symfony UX Native : Transformez votre app web en application mobile native

Et si vous pouviez transformer votre application Symfony en app mobile native, sans réécrire une seule ligne de code ? Avec UX Native et Hotwire Native, c'est possible. On construit ensemble un Carnet de Notes et on le prépare pour iOS et Android, pas à pas.

Et si vous pouviez transformer votre application Symfony en app mobile native, sans réécrire une seule ligne de code ? Avec UX Native et Hotwire Native, c'est possible. On construit ensemble un Carnet de Notes et on le prépare pour iOS et Android, pas à pas.
Mode de lecture :

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 nativeDétecte automatiquement si la requête vient d’une app Hotwire Native (via le User-Agent)
Configuration des cheminsDéfinit en PHP comment chaque URL se comporte dans l’app native (modale, push, pull-to-refresh…)
App Native (iOS/Android)WebViewServeur SymfonyCharge l'URL /notesGET /notes (User-Agent: Hotwire Native)NativeListener détecte le User-AgentHTML sans navbar webRendu dans le shell natifAjoute barre de titre + transitions nativesApp Native (iOS/Android)WebViewServeur Symfony

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 nullable
  • content : text, not nullable
  • createdAt : 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/new a priority: 1 pour ê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">&larr; 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">&larr; 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 :

  1. Détecter si la requête vient d’une app native ou d’un navigateur (étape 5)
  2. 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, lancez npm 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 :

  1. Ouvrez le panneau Network Conditions (Chrome) ou Settings > User Agent (Firefox)
  2. Désactivez “Use browser default” et ajoutez Hotwire Native au User-Agent
  3. 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/new doit s’ouvrir en modale (une feuille qui glisse depuis le bas)”
  • “La page /settings doit 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.

App native démarre
Télécharge /config/android_v1.json
L'app sait comment se comporter
/ → navigation classique
/notes/new → ouvrir en modale
Pull-to-refresh activé partout

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.
  • Configuration contient des settings (paramètres globaux envoyés à l’app) et des rules (règles ordonnées qui dictent le comportement par URL).
  • Rule associe des patterns (regex matchant les URLs visitées) à des properties (comportements natifs). Par exemple, le pattern .* matche toutes les URLs, tandis que /notes/new ne 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/web est 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_style pour contrôler l’apparence de la modale, Android a uri qui 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 possiblesDéfautEffet
contextdefault, modaldefaultNavigation standard ou ouverture en modale
presentationpush, pop, replace, replace_root, clear_all, refresh, nonedefaultType de transition entre écrans
pull_to_refresh_enabledtrue, falseiOS: true, Android: falseActive le “tirer pour rafraîchir”
animatedtrue, falsetrueActive/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ès cache:clear).


Récapitulatif

Voici tout ce qu’on a mis en place :

ComposantRôle
symfony/ux-nativeBundle 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 + RuleObjets PHP, définissent les règles de comportement natif
ux-native:dumpCommande, génère les fichiers JSON statiques dans public/
Fichier crééRôle
src/Native/AppNativeConfiguration.phpConfigurations des chemins iOS et Android
public/config/ios_v1.jsonConfiguration iOS (générée par ux-native:dump)
public/config/android_v1.jsonConfiguration 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é :

FichierRôleÉquivalent Symfony
AndroidManifest.xmlDéclare l’app et ses permissionsconfig/services.yaml
MainActivity.ktPoint d’entrée, indique l’URL de départUn contrôleur avec une route
NotepadApplication.ktCharge le JSON de path configurationConsomme ce que ux-native:dump a généré
activity_main.xmlLayout avec la WebView HotwireLe “template” de l’app
Fichiers GradleDépendances et buildcomposer.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.2 est l’alias Android pour 127.0.0.1 de 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 settings pour passer des feature flags ou des URLs de services à l’app native
  • Bridge avancé : explorez BridgeComponent pour 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 :

Back to Blog

Comments (0)

Loading comments...

Leave a Comment