# Native dialogs and notifications > **The rule:** native UI affordances — file pickers, message boxes, system notifications, tray icons — live on the **QML side**, not in PHP. PLAN.md §12 ([Native dialogs row](../PLAN.md#12-open-questions-and-risks)) treats this as a framework boundary, not an open question. ## Why this boundary The Qt host owns the window, the input loop, and the platform integration. Anything that needs to talk to the OS's native chrome (a save-file dialog drawn by `kdialog` / `NSSavePanel` / `IFileSaveDialog`, a notification routed through `org.freedesktop.Notifications` / `NSUserNotificationCenter` / `ToastNotification`) is reachable from Qt and unreachable from PHP. PHP can produce data and decide *what* should happen; QML decides *how to surface it natively*. Trying to dispatch a "show me a save-file dialog" from a Symfony controller means either polling the Qt side over HTTP (slow, ugly) or shipping a second Qt↔PHP transport just for UI side-effects (unnecessary). Don't. ## File pickers Use [`Qt.labs.platform.FileDialog`](https://doc.qt.io/qt-6/qml-qt-labs-platform-filedialog.html) — it draws the platform-native dialog, not Qt's fallback rendering. ```qml import QtQuick import QtQuick.Controls import Qt.labs.platform as Platform Button { text: "Open document…" onClicked: openDialog.open() Platform.FileDialog { id: openDialog title: "Open document" nameFilters: ["JSON files (*.json)", "All files (*)"] fileMode: Platform.FileDialog.OpenFile onAccepted: console.log("user picked:", currentFile) } } ``` Save-file is the same component with `fileMode: Platform.FileDialog.SaveFile`. Multi-select uses `OpenFiles` and `currentFiles` (plural). The dialog returns a `file://` URL — convert it for filesystem use with the standard QML idiom `Qt.url.toLocalFile(currentFile)` or pass the URL straight to `QFile` / `QNetworkAccessManager` if upload-as-URL fits. ## Confirmations / message boxes Use [`Qt.labs.platform.MessageDialog`](https://doc.qt.io/qt-6/qml-qt-labs-platform-messagedialog.html). Same idiom — declarative component, `open()` to show, signal handlers to react. ```qml Platform.MessageDialog { id: confirmDelete title: "Delete todo?" text: `“${todo.title}” will be removed permanently.` buttons: Platform.MessageDialog.Yes | Platform.MessageDialog.Cancel onAccepted: todoModel.removeRow(index) } ``` For a non-modal toast-style "Saved!" feel, use Qt Quick Controls' `ToolTip.show(text, timeoutMs)` rather than a MessageDialog — that's a *banner*, not a confirmation, and shouldn't grab focus. ## Notifications For "X has finished" / "you have a new Y" *system tray* notifications, use [`Qt.labs.platform.SystemTrayIcon`](https://doc.qt.io/qt-6/qml-qt-labs-platform-systemtrayicon.html) with `showMessage(title, body, icon, msecs)`: ```qml Platform.SystemTrayIcon { id: tray visible: true icon.source: "qrc:/icons/app.png" Connections { target: ImportJobs function onCompleted(job) { tray.showMessage("Import finished", `${job.rowCount} rows`) } } } ``` Caveats: - Cross-platform but **routed through the tray**. Users who hide the tray won't see the notification on Linux (the [XDG Notifications portal](https://flatpak.github.io/xdg-desktop-portal/) is the long-term fix; planned for a later release). - macOS: notifications go to the Notification Center even if the tray isn't visible — works without caveat. - Windows: same, via the action center. Richer notifications (action buttons, replies, persistent banners) need platform-specific code per OS and aren't in scope for v0.x. If your app needs them sooner, drop a `QtPlatformNotification` wrapper next to `BackendConnection` and surface it as a QML singleton — happy to merge. ## Folder pickers Same `FileDialog` component, `fileMode: Platform.FileDialog.OpenDirectory`. The native dialog hides files automatically. ## Standard paths Don't hard-code `~/Downloads` or `%USERPROFILE%`. Use [`Qt.labs.platform.StandardPaths`](https://doc.qt.io/qt-6/qml-qt-labs-platform-standardpaths.html): ```qml Platform.FileDialog { folder: Platform.StandardPaths.writableLocation(Platform.StandardPaths.DocumentsLocation) } ``` These resolve to the OS-correct location (`XDG_DOCUMENTS_DIR`, `~/Documents`, the Documents knownfolder, etc.) and stay correct under user policy / corporate config. ## Anti-patterns - **Don't** `POST /api/show-dialog`. The PHP side has no hook into the user's window manager. - **Don't** open a `QDialog` from a Doctrine listener. Doctrine listeners run inside the FrankenPHP child process; even if they could reach Qt, they'd block the worker. - **Don't** roll a custom QML "FileDialog" using `Window` + `ListView` to browse the filesystem. It looks wrong on every OS, can't access OS-restricted paths (sandboxed downloads, Photos, etc.), and reinvents what Qt already ships. ## When to publish a Mercure event vs open a dialog directly - **User initiates an action that needs confirmation** (delete, overwrite) → open the dialog from the QML handler that fired the action; the action only proceeds on `onAccepted`. - **Server-side event the user should be told about** (background job done, push notification) → publish a Mercure event on `app://event/`; QML's `MercureClient` listener fires the dialog or `tray.showMessage`. The split keeps the *trigger* close to the user's intent and the *side-effect* declarative on the QML side. ## See also - [QML API reference — `BackendConnection`, `MercureClient`](qml-api.md) — for handling server-pushed events that drive notifications. - [Update semantics](update-semantics.md) — for the `commandFailed` / `commandTimedOut` signals that often want toast or dialog feedback.