NativePHP for Symfony
GitHub

NativePHP Desktop — Runtime Contract

The wire protocol between a PHP application and the NativePHP Electron runtime. Extracted from NativePHP/desktop @ HEAD (2026-08-14), resources/electron/electron-plugin/src/. Framework-neutral by construction: nothing below mentions Laravel except where the current implementation leaks it, and those leaks are called out as such.

This is the spec a nativephp/symfony adapter implements, and the spec a nativephp/core package would encode. See ANALYSIS.md for the boot sequence and PLAN.md for the roadmap.


0. Transport and conventions

Channel A — app → runtime

  • Base URL http://127.0.0.1:{apiPort}/api/ — supplied to the PHP process as NATIVEPHP_API_URL (already includes the trailing /api/).
  • Port first free port in 4000–5000, chosen at runtime boot.
  • Auth X-NativePHP-Secret: {secret} on every request, from NATIVEPHP_SECRET. Mismatch → 403 with an empty body, before any routing (api/middleware.ts).
  • Body JSON (body-parser.json()). Send Content-Type: application/json.
  • Timeout the reference client uses 3600s. Several endpoints block on native UI (dialog/*, alert/message, system/prompt-touch-id) and will not return until the user acts. Do not set a short timeout.
  • /api/debug/* is mounted only when NODE_ENV === 'development' (api.ts). In production those requests 404. Treat as fire-and-forget.

Response conventions

Three shapes, and they are not consistent — a port must handle all three:

  1. 200 whose body is the status phrase, not JSON and not empty — most mutations. res.sendStatus(200) makes express send text/plain OK; a 404 from window/get/:id arrives as Not Found. Treat a body equal to the reason phrase for its status code as "no data" — and only that, so a genuine HTML error page is still visible as a problem. (This entry originally read "no body"; corrected after a client logged a JSON parse error on every successful mutation.)
  2. 200 with a JSON object — all reads, plus a few mutations.
  3. 200 sent before the work happens. Every menu-bar/* endpoint and both context endpoints call res.sendStatus(200) as their first statement, then act. A 200 from those is an acknowledgement of receipt, not of success — errors there are unobservable by the caller.

Error responses that do exist: 400 ({error: string}) from the encrypt/decrypt, print-to-pdf, and clipboard-image paths; 404 from window/get/:id; 410 from child-process/get/:alias and child-process/restart when the alias is unknown; 500 from shell/open-external and system/print.

Channel B — runtime → app

  • POST http://127.0.0.1:{phpPort}/_native/api/{events|booted}, same X-NativePHP-Secret header. Only those two endpoints exist.
  • Failures are silently swallowed (utils.ts::notifyLaravel, empty catch {}).
  • Every events post is also pushed to all renderers as an IPC native-event.

Channel C — runtime → renderer

Exposed by preload/index.mts via contextBridge, framework-agnostic:

SurfaceSignatureNotes
window.Native.on(event: string, cb: (payload, event) => void)Leading backslashes stripped from both sides before comparison.
window.Native.contextMenu(template: MenuTemplate[]) => voidPopped over the current window via @electron/remote.
window event native:initCustomEventFired once when preload finishes evaluating.
window.postMessage{type:'native-event', event, payload}For code that cannot use contextBridge.
IPC log{level, message, context}Written to the devtools console by preload.

1. Window — 22 endpoints

state.windows is a Record<developerId, BrowserWindow>. The id in every request is the developer-assigned string id, not Electron's numeric id. Unknown ids are silently ignored (state.windows[id]?.…) — no error.

MethodPathRequestResponse
POSTwindow/opensee below200
POSTwindow/close{id}200 — also deletes from state.windows
POSTwindow/show{id}200
POSTwindow/hide{id}200
POSTwindow/maximize{id}200
POSTwindow/unmaximize{id}200
POSTwindow/minimize{id}200
POSTwindow/fullscreen{id, fullscreen: bool}200
POSTwindow/reload{id}200
POSTwindow/resize{id, width, height}200 — parseInt'd
POSTwindow/position{id, x, y, animate}200 — parseInt'd
POSTwindow/title{id, title}200
POSTwindow/url{id, url}200 — ?_windowId={id} appended
POSTwindow/closable{id, closable: bool}200
POSTwindow/always-on-top{id, alwaysOnTop: bool}200
POSTwindow/window-button-visibility{id, windowButtonVisibility: bool}200 — macOS only
POSTwindow/set-zoom-factor{id, zoomFactor: float}200 — parseFloat'd
POSTwindow/show-dev-tools{id}200
POSTwindow/hide-dev-tools{id}200
GETwindow/current—WindowData
GETwindow/all—WindowData[]
GETwindow/get/{id}—WindowData, or 404

WindowData — 24 fields: id, x, y, width, height, title, alwaysOnTop, url, autoHideMenuBar, fullscreen, fullscreenable, kiosk, devToolsOpen, resizable, movable, minimizable, maximizable, closable, focusable, focused, hasShadow. (frame, titleBarStyle and trafficLightPosition are commented out upstream — do not expose them.)

⚠️ window/current calls BrowserWindow.getFocusedWindow().id with no null guard. When no window is focused (app backgrounded) it throws. Any client must treat this endpoint as fallible — see ANALYSIS.md §8.3.

window/open request — 39 keys, all optional except id, url, width, height — and zoomFactor, which is optional only on paper: the runtime runs setZoomFactor(parseFloat(zoomFactor)) on dom-ready with no guard, so omitting it yields NaN and renders the page at an absurd zoom. Always send it (1.0 is the upstream default). See M2-RESULTS.md finding 2.

text
id  url  x  y  width  height  minWidth  minHeight  maxWidth  maxHeight
frame  title  backgroundColor  transparency  vibrancy  hasShadow
alwaysOnTop  resizable  movable  minimizable  maximizable  closable
focusable  skipTaskbar  hiddenInMissionControl  autoHideMenuBar
titleBarStyle  trafficLightPosition  windowButtonVisibility
fullscreen  fullscreenable  kiosk  zoomFactor  showDevTools
rememberState  webPreferences  preventLeaveDomain  preventLeavePage
suppressNewWindows

Behaviour worth porting deliberately:

  • Idempotent: if id already exists, the window is shown and focused and 200 returns — no new window, no error.
  • rememberState: true persists geometry to window-state-{id}.json via electron-window-state; the stored size wins over the requested one only if resizable is truthy.
  • webPreferences is merged, with {sandbox:false, preload:…, contextIsolation:true} force-applied last (webPreferences.ts) — those three cannot be overridden.
  • preventLeaveDomain / preventLeavePage install a will-navigate guard comparing hostname / (origin + pathname) against the initial URL.
  • The window is created with show:false and shown on did-finish-load, unless NATIVEPHP_NO_FOCUS is set and the window is already visible.
  • page-title-updated is preventDefault()ed — the document <title> never changes the native window title. Use window/title.
  • Emits 9 events (§12). But not for its own resizes: window.ts listens for Electron's resized, which fires on user-driven resizes, not setSize(). A window/resize call succeeds and window/get reflects it, yet no WindowResized arrives — verified empirically in the M1 spike. An app cannot use the event to observe resizes it initiated itself.

2. App — 19 endpoints

MethodPathRequestResponse
POSTapp/quit—200
POSTapp/relaunch—no response — relaunches then quits
POSTapp/show—200
POSTapp/hide—200
GETapp/is-hidden—{is_hidden: bool}
GETapp/locale—{locale: string}
GETapp/locale-country-code—{locale_country_code: string}
GETapp/system-locale—{system_locale: string}
GETapp/app-path—{path: string}
GETapp/path/{name}—{path: string}
GETapp/version—{version: string}
GETapp/badge-count—{count: int}
POSTapp/badge-count{count: int}200
POSTapp/recent-documents{path}200
DELETEapp/recent-documents—200
GETapp/open-at-login—{open: bool}
POSTapp/open-at-login{open: bool}200
GETapp/is-emoji-panel-supported—{supported: bool}
POSTapp/show-emoji-panel—200

app/path/{name} accepts Electron's app.getPath() names (home, appData, userData, sessionData, temp, exe, module, desktop, documents, downloads, music, pictures, videos, recent, logs, crashDumps). An invalid name throws inside Electron.

app/relaunch never sends a response. A client that awaits it will hang until the process dies. Fire and forget.


3. System — 10 endpoints

MethodPathRequestResponse
GETsystem/can-prompt-touch-id—{result: bool}
POSTsystem/prompt-touch-id{reason}200, or 400 {error}
GETsystem/can-encrypt—{result: bool}
POSTsystem/encrypt{string}{result: base64}, or 400 {error}
POSTsystem/decrypt{string: base64}{result: string}, or 400 {error}
GETsystem/printers—{printers: PrinterInfo[]}
POSTsystem/print{printer, html, settings?}200 / 500
POSTsystem/print-file{path, printer, settings?}200 / 500 {error}
POSTsystem/print-to-pdf{html, settings?}{result: base64} / 400 {error}
GETsystem/theme—{result: 'system'|'light'|'dark'}
POSTsystem/theme{theme}{result: theme}

system/printers reads from BrowserWindow.getAllWindows()[0] — throws if no window exists. The HTML print endpoints spin up a hidden BrowserWindow and load the HTML as a data URL; print merges {silent:true, deviceName:printer} under the caller's settings.

print-file takes a path on the runtime's filesystem, not the PHP process's, and is PDF-only: it parses the file's MediaBox to size the page and answers 500 {error} when it cannot read the file or cannot find one. It then waits 1.5s after load for PDFium to paint before starting the job, so budget a longer timeout than the other calls.

print-to-pdf builds its data URL as data:text/html;base64;charset=UTF-8,${html} — a malformed media type (;base64; is not a valid parameter and the payload is passed unencoded). Send percent-encoded HTML and expect quirks.


4. Menu bar / tray — 9 endpoints

All nine send 200 before doing anything. Errors are invisible.

MethodPathRequest
POSTmenu-bar/createsee below
POSTmenu-bar/show— (throws if no active menu bar)
POSTmenu-bar/hide— (same)
POSTmenu-bar/resize{width, height}
POSTmenu-bar/label{label}
POSTmenu-bar/tooltip{tooltip}
POSTmenu-bar/icon{icon: path}
POSTmenu-bar/context-menu{contextMenu: MenuItem[]}
POSTmenu-bar/show-context-menu—

menu-bar/create request: url, label, tooltip, icon, width, height, minWidth, minHeight, maxWidth, maxHeight, resizable, alwaysOnTop, vibrancy, backgroundColor, transparency, showDockIcon, onlyShowContextMenu, windowPosition (default trayCenter), showOnAllWorkspaces (default false), contextMenu, webPreferences.

label, tooltip and url are optional only on paper. The runtime passes label to Tray.setTitle() and, in tray-only mode, tooltip to Tray.setToolTip() with no guard; both take a required string and refuse anything else, so an absent key throws after the 200 has gone out. url becomes the popover's index, and the vendored menubar library substitutes file://<appPath>/index.html, which a NativePHP build does not have. Always send them — '', '' and url('/') are the upstream defaults.

Two distinct modes:

  • onlyShowContextMenu: true → a bare Tray with a context menu, no window. Hides the dock icon unless showDockIcon.
  • otherwise → a menubar() instance (vendored library at libs/menubar/) with preloadWindow: true, activateWithApp: false.

Calling create a second time destroys the existing tray and suppresses the MenuBarCreated event — so MenuBarCreated fires only on first creation. Default icon: state.icon with icon.png → IconTemplate.png. Default context menu when none supplied: a single {role:'quit'} item. right-click also hides the window and pops the context menu (non-tray-only mode).


5. Menu, dock, context menu — 11 endpoints

MethodPathRequestResponse
POSTmenu{items: MenuItem[]}200 — replaces the application menu
POSTdock{items: MenuItem[]}200
POSTdock/show—200
POSTdock/hide—200
POSTdock/icon{path}200
POSTdock/bounce{type: 'critical'|'informational'}200 — id stored in state
POSTdock/cancel-bounce—200 — cancels the stored id
GETdock/badge—{label: string}
POSTdock/badge{label}200
POSTcontext{entries: MenuItem[]}200 (sent first)
DELETEcontext—200 (sent first) — disposes the handler
POSTprogress-bar/update{percent: float}200 — applied to all windows

All dock/* endpoints call app.dock.*, which is undefined on Windows and Linux — those requests throw. Guard by platform on the client.

progress-bar/update has no id: it sets the progress bar on every window. -1 clears it (Electron convention).

Consumed by api/helper/index.ts::compileMenu, applied recursively to submenu (which may be either an array or {submenu: [...]}).

typeExtra keysBehaviour
linkurl, openInBrowser?, event?, id?, labelRewritten to normal. On click: fires the event, then either shell.openExternal(url) or navigates the focused window to url (with ?_windowId= appended).
checkbox / radiochecked, event?, id?, labelToggles checked in the runtime's copy then fires the event.
rolerole, label?Reduced to {role, label?} — every other key is dropped.
anything elselabel, id?, event?, plus any Electron MenuItemConstructorOptionsDefault click handler fires the event.

Click payload — {item: {id, label, checked}, combo}, dispatched under item.event if set, else \Native\Desktop\Events\Menu\MenuItemClicked.

checkbox/radio state is mutated on the runtime side only. The app is told via the event payload; it is not queryable afterwards.


6. Dialogs and alerts — 4 endpoints

MethodPathRequestResponse
POSTdialog/open{title?, defaultPath?, buttonLabel?, filters?, message?, properties?, windowReference?}{result: string[] | undefined}
POSTdialog/savesame{result: string | undefined}
POSTalert/message{message, type?, title?, detail?, buttons?, defaultId?, cancelId?}{result: int} — index of the clicked button
POSTalert/error{title, message}{result: true}

All four are synchronous and blocking (showOpenDialogSync, showMessageBoxSync, showErrorBox) — the runtime's event loop is stalled until the user responds, so no other API request is served meanwhile. result is undefined on cancel.

windowReference is a window id; when it resolves the dialog is modal to that window, otherwise app-modal. Null/undefined keys are stripped before the Electron call (utils.ts::trimOptions), so filters etc. can be omitted safely.


7. Clipboard — 7 endpoints

All accept an optional ?type=clipboard|selection query parameter (default clipboard; selection is X11-only).

MethodPathRequestResponse
GETclipboard/text—{text: string}
POSTclipboard/text{text}{text}
GETclipboard/html—{html: string}
POSTclipboard/html{html}{html}
GETclipboard/image—{image: dataURL | null}
POSTclipboard/image{image: dataURL}200, or 400 {error}
DELETEclipboard—200

GET clipboard/image returns null for an empty clipboard, otherwise a PNG data URL.


8. Shell, screen, settings, process — 13 endpoints

MethodPathRequestResponse
POSTshell/show-item-in-folder{path}200
POSTshell/open-item{path}{result: string} — '' on success, else an error message
POSTshell/open-external{url}200, or 500 {error}
DELETEshell/trash-item{path}200, or 400
GETscreen/displays—{displays: Display[]}
GETscreen/primary-display—{primaryDisplay: Display}
GETscreen/cursor-position—Point — bare object, not wrapped
GETscreen/active—Display — bare object; nearest to the cursor
GETsettings/{key}—{value: any | null}
POSTsettings/{key}{value: any}200
DELETEsettings/{key}—200
DELETEsettings—200 — clears everything
GETprocess—{pid, platform, arch, uptime} — the Electron process

screen/cursor-position and screen/active are the only two endpoints in the whole API that return an unwrapped object. Do not assume a wrapper key.

Settings are backed by electron-store (a JSON file in userData) and are not the same store as window-state-*.json. Every write fires a SettingChanged event per changed key (state.ts), including writes made by the app itself — so a naive listener that writes on change will loop.

Dots in a key are object paths. electron-store uses dot-prop, so POST settings/a.b stores {"a":{"b":…}} and GET settings/a.b reads it back — but the SettingChanged event carries the root key a, because the store watcher diffs top-level keys only. Verified: writing diagnostics.ran produced an event for diagnostics.

DELETE shell/trash-item responds res.status(400).json() with no argument on failure, which express rejects — see ANALYSIS.md §8.4.


8b. Global shortcuts — 3 endpoints

MethodPathRequestResponse
POSTglobal-shortcuts{key, event}200
DELETEglobal-shortcuts{key}200 — note: body on a DELETE
GETglobal-shortcuts/{key}—{isRegistered: bool}

key is an Electron accelerator (CommandOrControl+Shift+K). event is a caller-chosen event name, pushed back with payload [key] (positional) whenever the accelerator fires — this is the clearest case of the dynamic-event-name path in §12 being load-bearing rather than a fallback.

globalShortcut.register() returns false when another application already owns the accelerator, but the endpoint discards that and answers 200 regardless. Confirm with GET global-shortcuts/{key} if registration matters.

Re-registering the same key replaces the previous callback. Shortcuts are process-wide and are released on quit.


9. Notifications — 1 endpoint

MethodPathRequestResponse
POSTnotificationsee below{reference: string}

Request: title, body, subtitle, silent, icon, hasReply, timeoutType, replyPlaceholder, sound, urgency, actions, closeButtonText, toastXml, event?, reference?.

  • reference is the correlation id returned to the caller and echoed in all four notification events. Supply your own or accept the generated {timestamp}.{random}.
  • event overrides the click event name; the other three are fixed.
  • sound containing / or \ (and not http(s)://) is treated as a **local file**: the native sound is silenced and the file is played via play-sound. A missing file is reported to renderers as a log IPC message, not to PHP.
  • The runtime keeps a strong reference in state.notifications until click or close, working around electron#16922 — so a notification that is neither clicked nor closed leaks until app exit.

10. Power monitor — 4 endpoints

MethodPathRequestResponse
GETpower-monitor/get-system-idle-state?threshold= (seconds, default 60){result: 'active'|'idle'|'locked'|'unknown'}
GETpower-monitor/get-system-idle-time—{result: int} seconds
GETpower-monitor/get-current-thermal-state—{result: 'unknown'|'nominal'|'fair'|'serious'|'critical'}
GETpower-monitor/is-on-battery-power—{result: bool}

Listeners are registered at module import time, not per request — the 8 power events flow whether or not the app ever calls these endpoints.


11. Auto-updater and child processes — 11 endpoints

MethodPathRequestResponse
POSTauto-updater/check-for-updates—200
POSTauto-updater/download-update—200
POSTauto-updater/quit-and-install—200
POSTchild-process/startProcessSettingsProcessHandle
POSTchild-process/start-nodeProcessSettingsProcessHandle
POSTchild-process/start-phpProcessSettingsProcessHandle
POSTchild-process/stop{alias}200
POSTchild-process/restart{alias}ProcessHandle, or 410
POSTchild-process/message{alias, message}200 (also 200 if unknown)
GETchild-process/get/{alias}—ProcessHandle, or 410
GETchild-process—Record<alias, ProcessHandle>

ProcessSettings: alias (unique key), cmd: string[], cwd, env, spawnTimeout (default 30000), persistent, handlesOwnShutdown, iniSettings (php only).

ProcessHandle: {pid, proc, settings} — proc is an Electron UtilityProcess and serialises to a useless object; read pid. On the synchronous return path pid is null because spawn has not fired yet, so pid is only reliable via GET child-process/get/{alias} or the ProcessSpawned event.

Semantics:

  • start runs the command under Electron's bundled Node via a shim (dist/server/childProcess.js); start-node is the same with USE_NODE_RUNTIME=1; start-php prepends the PHP binary plus merged -d key=value ini flags and injects the full NATIVEPHP_* env.
  • start* is idempotent by alias — an existing alias returns the existing handle and starts nothing.
  • persistent: true restarts the process 1s after any exit (a watchdog, not a supervisor: no backoff beyond that fixed delay).
  • stop sets persistent = false first, then either SIGTERMs the single pid (handlesOwnShutdown, non-Windows) or tree-kills.
  • restart stops, polls for deletion up to 5s, then starts with the captured settings. Note it captures settings before stopping and {...undefined} yields {}, so the 410 branch is unreachable — an unknown alias starts a process with empty settings.
  • On quit: framework processes are killed, then stopAllProcesses(), then up to 12s of drain waiting for state.processes to empty.

Laravel leak: start-php special-cases cmd[0] === 'artisan' to prepend the secure-bundle path. A framework-neutral runtime keys this off the manifest's CLI name (§14).


12. Reverse channel — 46 events

POST /_native/api/events with {event: string, payload?: array|object}.

Reference dispatch (DispatchEventFromAppController):

php
class_exists($event) ? event(new $event(...$payload)) : event($event, $payload);

Two behaviours any port must reproduce:

  1. ...$payload spreads positionally for list payloads and as named arguments for string-keyed payloads. Both appear below. Getting this wrong breaks roughly half the events.
  2. Unknown event names still dispatch, as a name + array payload. Apps choose their own event names for shortcuts, menu items and notifications, so this path is load-bearing, not a fallback.

Event names arrive with inconsistent leading backslashes. Normalise before comparing.

Event (under Native\Desktop\Events\)PayloadKind
Windows\WindowBlurred[id]positional
Windows\WindowFocused[id]positional
Windows\WindowMinimized[id]positional
Windows\WindowMaximized[id]positional
Windows\WindowUnmaximized[id]positional
Windows\WindowShown[id]positional
Windows\WindowHidden[id]positional
Windows\WindowClosed[id]positional
Windows\WindowResized[id, width, height]positional
Windows\WindowFullscreened[id]positional
Windows\WindowUnfullscreened[id]positional
App\OpenedFromURL[url] or {url}both — open-url sends a list, Windows/Linux second-instance sends an object
App\OpenFile[path]positional
Menu\MenuItemClicked{item:{id,label,checked}, combo}named
MenuBar\MenuBarCreatednone—
MenuBar\MenuBarShownnone—
MenuBar\MenuBarHiddennone—
MenuBar\MenuBarClicked{combo, bounds, position}named
MenuBar\MenuBarRightClicked{combo, bounds}named
MenuBar\MenuBarDoubleClicked{combo, bounds}named
MenuBar\MenuBarDroppedFiles[files]positional (one arg, an array)
Notifications\NotificationClicked{reference, event}named — overridable name
Notifications\NotificationActionClicked{reference, index, event}named
Notifications\NotificationReply{reference, reply, event}named
Notifications\NotificationClosed{reference, event}named
ChildProcess\ProcessSpawned[alias, pid]positional
ChildProcess\ProcessExited{alias, code}named
ChildProcess\MessageReceived{alias, data}named
ChildProcess\ErrorReceived{alias, data}named
ChildProcess\StartupError{alias, error}named
PowerMonitor\PowerStateChanged{state: 'on-ac'|'on-battery'}named
PowerMonitor\ThermalStateChanged{state}named
PowerMonitor\SpeedLimitChanged{limit}named
PowerMonitor\ScreenLockednone—
PowerMonitor\ScreenUnlockednone—
PowerMonitor\Shutdownnone—
PowerMonitor\UserDidBecomeActivenone—
PowerMonitor\UserDidResignActivenone—
Settings\SettingChanged{key, value}named
AutoUpdater\CheckingForUpdatenone—
AutoUpdater\UpdateAvailable{version, files, releaseDate, releaseName, releaseNotes, stagingPercentage, minimumSystemVersion}named
AutoUpdater\UpdateNotAvailablesame 7 keysnamed
AutoUpdater\UpdateCancelledsame 7 keysnamed
AutoUpdater\UpdateDownloadedsame 7 keys + downloadedFilenamed
AutoUpdater\DownloadProgress{total, delta, transferred, percent, bytesPerSecond}named
AutoUpdater\Error{name, message, stack}named

Note ChildProcess\ProcessSpawned is positional while every other ChildProcess event is named — a genuine inconsistency to encode, not to tidy.

App\ApplicationBooted is not in this table: it is dispatched by the app itself inside the booted handler, never pushed by the runtime.

Caller-named events are additional to the 44 and have no fixed names. Three sources: global-shortcuts (payload [key], positional), any MenuItem carrying an event key (payload {item, combo}), and notification's event override (payload {reference, event}). A port that only handles the 44 class names will drop all three.

POST /_native/api/booted

No body. The app must respond by opening its windows. Called at the end of runtime boot, and again on macOS activate when no windows are visible. Must be idempotent — window/open on an existing id is a no-op show+focus, so the natural implementation already is.

POST /api/broadcast (app → runtime → renderers)

{event: string, payload: any} → IPC native-event to every window and the menu bar window. Nothing reaches PHP. This is the app's route to its own front end.


13. Environment contract

Set on every PHP process the runtime spawns (php.ts::getDefaultEnvironmentVariables).

Framework-neutral (17)

VariableValue
APP_ENVlocal in dev, else production — Laravel's env names, hardcoded. Symfony 8 rejects local outright (Kernel::getAllowedEnvs()), so this is one of the values a manifest must parameterise.
APP_DEBUGtrue in dev, else false
NATIVEPHP_RUNNINGalways true — the flag that says "you are inside the runtime"
NATIVEPHP_API_URLhttp://127.0.0.1:{apiPort}/api/ — only once the API is up
NATIVEPHP_SECRETthe 32-char shared secret — only once the API is up
NATIVEPHP_STORAGE_PATH{userData}/storage
NATIVEPHP_DATABASE_PATH{userData}/database/database.sqlite
NATIVEPHP_{USER_HOME,APP_DATA,USER_DATA,DESKTOP,DOCUMENTS,DOWNLOADS,MUSIC,PICTURES,VIDEOS,RECENT}_PATHthe matching app.getPath(); empty string if unavailable
NATIVEPHP_EXTRAS_PATH{resources}/../extras packaged, else {APP_PATH}/extras

NATIVEPHP_API_URL and NATIVEPHP_SECRET are absent from the native:config and native:php-ini invocations, which run before the API server exists. A command that needs the client must tolerate their absence.

Laravel-shaped (8) — these are the ones a manifest should generalise: APP_ENV's values (see above), LARAVEL_STORAGE_PATH, plus (secure builds only) APP_SERVICES_CACHE, APP_PACKAGES_CACHE, APP_CONFIG_CACHE, APP_ROUTES_CACHE, APP_EVENTS_CACHE, and VIEW_COMPILED_PATH (currently commented out).

Optional: NIGHTWATCH_TOKEN, NIGHTWATCH_INGEST_URI.

Read, not written, by the runtime: APP_PATH (dev/testing app root), NODE_ENV, SHELL_VERBOSITY (>0 enables PHP command logging), NATIVEPHP_NO_FOCUS.

Default PHP ini flags always passed as -d: memory_limit=512M, curl.cainfo={caCert}, openssl.cafile={caCert}, merged under whatever native:php-ini returned, merged under a child process's own iniSettings.


14. What the runtime requires of the app

Seven requirements. Current Laravel values on the left of the arrow, the manifest key that should replace them on the right.

RequirementTodayProposed manifest key
CLI entrypointartisancli
Config command printing JSON to stdoutnative:configcommands.config
PHP-ini command printing JSON to stdoutnative:php-inicommands.phpIni
php -S router scriptvendor/laravel/framework/src/Illuminate/Foundation/resources/server.phprouter
Document-root cwdpublicdocroot
Dirs to create + seed under userDatastorage/framework/{cache,sessions,views,testing}, storage/logs, bootstrap/cachewritableDirs[]
Cache-path env vars (secure builds)APP_*_CACHEcacheEnv{}
Boot / event endpoints/_native/api/booted, /_native/api/eventsfixed, no key needed

Optional lifecycle commands, skipped cleanly if absent: optimize (pre-serve, prod only), migrate --force (pre-serve, prod only, version-gated), schedule:run (every 60s), nightwatch:agent.

Config keys the runtime consumes

Of the whole nativephp config, index.ts reads exactly five:

text
app_id                                   → setAppUserModelId()
deeplink_scheme                          → setAsDefaultProtocolClient()
updater.enabled                          → checkForUpdatesAndNotify()
updater.default                          → selects the provider key
updater.providers.{default}.public_url    → setFeedURL({provider:'generic', url})

Everything else in that file is read by the PHP-side build tooling only. A minimal conforming native:config emits those five keys.

Security model

Three layers, all sharing one secret:

  1. Cookie _php_native = {secret}, set by the runtime into the Electron session for http://localhost:{phpPort}.
  2. Header X-NativePHP-Secret: {secret}, injected into every renderer request to http://127.0.0.1:{phpPort}/* via webRequest.onBeforeSendHeaders, and set explicitly on notifyLaravel posts.
  3. App-side middleware: accept if either matches, else 403. Exempt the cookie-issuing route only.

The PHP dev server listens on a real loopback TCP port; this secret is the only thing separating the app from any local browser or process that can reach it. Port it faithfully.


15. Coverage

SectionEndpoints
Window22
App19
System11
Menu bar9
Menu / dock / context / progress12
Child process8
Global shortcuts3
Clipboard7
Shell4
Screen4
Settings4
Power monitor4
Dialog2
Alert2
Auto-updater3
Notification1
Process1
Broadcast1
Debug (dev only)1
Total118

Plus 2 inbound endpoints (_native/api/{events,booted}), 46 runtime-pushed event types, 24 environment variables, and 5 consumed config keys.