Panel Filament en app mobile iOS/Android avec NativePHP
Embarque Filament 5 directement dans une app mobile native, grâce à NativePHP pour Mobile.
J'avais une question idiote en tête depuis quelques mois : est-ce qu'on peut faire tenir un panel Filament dans une vraie application mobile, sans serveur distant, sans WebView pointant vers un dashboard hébergé quelque part ? Juste l'app, le téléphone, et tout ce qu'il faut pour exécuter Laravel localement, hors-ligne.
La réponse courte : oui. Avec NativePHP for Mobile, on bundle un runtime PHP dans un projet iOS/Android natif, et Livewire tourne directement sur l'appareil. J'ai poussé un proof-of-concept sur GitHub : native-filamentphp. Voici ce que j'en retiens.
La stack
Le compatibility puzzle est la première chose à régler avant d'écrire la moindre ligne de code :
| Composant | Version |
|---|---|
| PHP | 8.5 (build ICU) |
| Laravel | 13 |
| Livewire | 4 |
| Filament | 5 |
| NativePHP Mobile | 3 |
nativephp/mobile-device |
1 |
Pourquoi Filament v5 et pas v4 ? Filament v4 est épinglé sur Laravel 11/12 + Livewire 3. NativePHP Mobile 3 demande PHP 8.5 et la stack Laravel 13. Donc soit on reste sur Laravel 12 + Filament 4, soit on passe en Laravel 13 + Filament 5. J'ai choisi la deuxième option, parce que c'est la combinaison sur laquelle l'écosystème va se stabiliser.
Autre détail à connaître : Filament a besoin de l'extension intl. Quand on installe les projets natifs, il faut le drapeau --with-icu, sinon les pages plantent au premier Number::format() :
php artisan native:install both --with-icu
Le panel est l'app
Sur une app mobile mono-utilisateur, beaucoup des conventions Filament tombent. J'ai donc fait trois choix opposés à ce qu'on ferait sur un back-office classique :
- Le panel est monté à la racine (
->path('')). Pas de/admin, pas de page d'accueil. L'utilisateur ouvre l'app, il est sur le dashboard. - Pas d'authentification. L'appareil est déjà déverrouillé par l'OS. Si l'app est partagée, on remet
->login()et->authMiddleware([Authenticate::class])— mais pour un PoC mono-user, c'est du bruit. - Une seule ressource pour valider la chaîne : un CRUD
Postgénéré avecphp artisan make:filament-resource Post --generate. L'objectif n'est pas de faire une vraie app, c'est de prouver que les formulaires et les tables Filament fonctionnent réellement sur appareil.
Au démarrage de l'app, voilà ce qui se passe : le runtime PHP embarqué démarre, Laravel boote, Filament rend la dashboard, Livewire s'hydrate. Tout ça, sur le téléphone, sans une seule requête réseau.
Le piège du safe-area iOS
Premier vrai souci à régler une fois l'app qui tourne sur le simulateur : le bouton menu de Filament passait sous la barre de statut iOS, et le bas de la page collait à la home indicator. Classique des WebViews mobiles.
La solution tient en quelques lignes, injectées via un PanelsRenderHook::HEAD_END dans AdminPanelProvider :
->renderHook(PanelsRenderHook::HEAD_END, fn (): string => <<<'HTML'
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<style>
.fi-topbar,
.fi-sidebar-header,
.fi-sidebar-nav { padding-top: env(safe-area-inset-top); }
.fi-main-ctn { padding-bottom: max(env(safe-area-inset-bottom), 1rem); }
</style>
HTML);
Le détail qui m'a fait perdre une heure : sans viewport-fit=cover dans la meta viewport, iOS résout tous les env(safe-area-inset-*) à 0. Vos règles CSS sont correctes, mais elles ne s'appliquent jamais. Le drapeau est obligatoire.
À noter aussi : on ne touche pas au padding horizontal du topbar. Filament met déjà un gutter d'1rem sur .fi-main, et padder les enfants directs du topbar pousse (ou cache) le bouton hamburger. Donc on ne padde que le top.
Parler à l'appareil : les plugins NativePHP
Un panel Filament qui ne sait rien de l'appareil sur lequel il tourne, c'est juste une PWA déguisée. Pour aller plus loin, NativePHP expose des bridge functions — des fonctions PHP qui appellent du code natif iOS/Android.
J'ai branché nativephp/mobile-device pour récupérer infos de la plateforme et de la batterie, et j'ai écrit un widget StatsOverviewWidget qui les affiche sur le dashboard :
use Native\Mobile\Facades\Device;
$info = json_decode(Device::getInfo(), true) ?? [];
$battery = json_decode(Device::getBatteryInfo(), true) ?? [];
$platform = match ($info['platform'] ?? null) {
'ios' => 'iOS',
'android' => 'Android',
default => 'Browser (dev)',
};
Le default => 'Browser (dev)' est clé : la bridge function n'existe que dans le shell natif. Quand vous lancez php artisan serve dans votre navigateur, les valeurs retombent gracieusement sur des em-dashes, sans if (function_exists(...)) partout. Ça permet de développer 95 % du temps dans Chrome, et de ne lancer le simulateur que pour valider les fonctionnalités natives.
Le piège de l'opt-in des plugins
Voilà la partie qui m'a coûté le plus de temps, et qui n'est pas évidente dans la doc : NativePHP ne compile pas automatiquement les plugins installés via Composer. Chaque plugin doit être opted in explicitement.
Le flow complet :
# 1. Installer
composer require nativephp/mobile-device
# 2. Publier le central provider (une seule fois)
php artisan vendor:publish --tag=nativephp-plugins-provider
# 3. Enregistrer le plugin dans NativeServiceProvider::plugins()
php artisan native:plugin:register nativephp/mobile-device
# 4. Rebuild l'app native pour que les bridge functions soient bakées dans le binaire
php artisan native:run ios
Si on saute l'étape 3, le plugin est dans vendor/, mais Device::getInfo() retourne null. Pas d'erreur, juste du silence. php artisan native:plugin:list permet de vérifier ce qui est réellement câblé.
Lancer le projet
Dans le navigateur, c'est du Laravel classique :
composer install
npm install && npm run build
php artisan migrate
php artisan serve
Sur appareil ou simulateur :
# iOS (nécessite Xcode)
php artisan native:run ios
# Android (nécessite Android Studio + émulateur lancé)
php artisan native:emulator
php artisan native:run android
# Live-reload des changements de fichiers
php artisan native:watch
Le live-reload fonctionne très bien — édition d'un Blade, rebuild Vite, le panel se rafraîchit dans le simulateur. La boucle de feedback est plus proche d'un dev web classique que d'un dev mobile natif.
Ce qu'il faut savoir avant d'aller plus loin
Quelques choses que j'aurais aimé qu'on me dise avant de commencer :
- Filament est desktop-first. Les tables et les formulaires complexes fonctionnent sur téléphone, mais ressemblent à un panel admin rétréci, pas à une UI mobile native. C'est OK pour un outil interne. Pour une app grand public, il faudra customiser sérieusement.
- SQLite uniquement. NativePHP Mobile bundle SQLite avec l'app. Une base distante demande des appels réseau et casse la nature offline-first du wrapper.
- Pas de processus long-running. Pas de queue worker, pas de scheduler, pas de broadcasting. Toute fonctionnalité côté serveur qui assume un host persistant doit être adaptée. Le téléphone n'a pas de second processus pour faire tourner un worker.
- Le cold start n'est pas instantané. Booter Laravel + Filament prend quelques centaines de ms au lancement de l'app. C'est tolérable, mais ce n'est pas une app native qui démarre en 50 ms.
Pour qui c'est intéressant ?
Honnêtement ? C'est encore exploratoire. Mais je vois trois cas où ça commence à avoir du sens :
- Un outil interne mono-utilisateur : un commercial qui prend des notes terrain, un livreur qui coche des étapes, un technicien qui remplit un rapport. Pas besoin de back-end, tout reste sur l'appareil, sync ultérieure via une simple API si besoin.
- Une app de prototypage pour valider une idée auprès de quelques utilisateurs sans monter d'infra serveur.
- Tout cas où l'offline est obligatoire et où une vraie base de données relationnelle (avec relations, migrations, validations) est plus pertinente que du localStorage.
Pour le reste — apps grand public, multi-utilisateurs, fonctionnalités natives avancées — on n'y est pas encore. Mais NativePHP avance vite, et savoir que Filament tourne déjà dessus aujourd'hui ouvre un terrain de jeu intéressant.
Le code est dispo sur GitHub si vous voulez forker et expérimenter. Et si vous trouvez d'autres pièges (il y en a forcément), je suis preneur.