nativephp/desktop
NativePHP for Desktop lets Laravel developers build native desktop applications using familiar PHP and Laravel tools. Package apps for Windows, macOS, and Linux with native windows, menus, filesystem access, notifications, and more—no new language required.
Install and scaffold first, code second.
composer require nativephp/desktop
php artisan native:install # downloads the Electron shell + PHP binaries, publishes config
php artisan native:serve # (alias native:dev) opens the app window with hot reload
native:install is the important step to understand: it publishes config/nativephp.php, creates a nativephp/ directory containing the Electron main/preload process and its package.json, and drops a NativeAppServiceProvider into app/Providers. That triple is your entire extension surface — the config for windows, the provider for boot-time native calls (menus, tray, shortcuts), and the JS folder for anything the PHP bridge doesn't expose.
Where to look first, in order:
config/nativephp.php — window defaults, app version, updater provider, bundled PHP binary settings.app/Providers/NativeAppServiceProvider::boot() — this is where a desktop app "starts". Menus, tray icons, global shortcuts, and the first Window::open() live here.routes/web.php — the app is still just Laravel served over HTTP; your existing routes render inside the shell.config/logging.php + storage/logs/laravel.log — your primary debugger (see gotchas).The canonical first use case is wrapping an existing internal Laravel app (dashboard, admin panel, CLI companion) into a desktop binary: SQLite database, native menu, system-tray icon, and desktop notifications. Start there rather than a greenfield app — it exercises everything (routing, DB, storage, native APIs) without forcing you to learn the whole API surface at once.
Minimum viable loop:
// NativeAppServiceProvider::boot()
Window::open()
->id(1)
->title(config('app.name'))
->width(1280)->height(800)
->rememberState(); // window position/size persisted across launches
Then php artisan native:serve, hit the native menu, drop a Notification::title('Hi')->show() in a controller, and you've validated the whole pipeline including the PHP→Electron bridge.
Keep the browser path alive. The highest-leverage habit with this package is to treat native as progressive enhancement. Put every native call behind a small service (App\Native\Ui) that checks whether it's running under the shell, so php artisan serve still works and your feature tests don't explode:
class Ui {
public function notify(string $title, string $body): void {
if (! $this->isNative()) return; // System::isNative() or a config flag
Notification::title($title)->body($body)->show();
}
}
You get fast iteration in the browser, CI-able tests, and a single seam to swap in a fake.
Event-driven glue, not polling. The Electron main process dispatches Laravel events into your app; the idiomatic pattern is to build menus/trays with stable IDs in the service provider and handle clicks in listeners:
// boot()
Menu::create()
->item('New Invoice', 'new-invoice')
->item('Preferences', 'prefs')
->separator()
->item('Quit', 'quit');
// app/Listeners/HandleMenuItemClicked.php
match ($event->id) {
'new-invoice' => Window::open()->url('/invoices/create'),
'prefs' => Window::open()->route('settings'),
'quit' => App::quit(),
};
The same pattern applies to WindowClosed, WindowFocus, TrayClicked, NotificationClicked, GlobalShortcut — all are ordinary Laravel events, so they queue, test, and dispatch like anything else.
Multi-window as a real feature. Each Window::open() is a separate BrowserWindow pointed at the same Laravel server: sessions and DB are shared, JS state is not. Use this for the "main window + detached inspector/preferences" layout rather than modals, and use ->route('name') or ->url() with relative paths so it works in dev (Vite on :5173) and prod (bundled server on an ephemeral port) alike.
Tray-first for utilities. For menu-bar/status apps, combine Tray::create()->icon(...)->tooltip(...) with a config window that's hidden instead of closed — intercept WindowClosed and call Window::hide() unless a real quit was requested. This is the shape most internal tools want, and it's where the package feels genuinely native rather than "a browser in a box."
Native capability cheat-sheet worth wiring early because they're each one-liners: Clipboard::text()/->write(), Dialog::openFile()/->saveFile(), Shell::openExternal($url) (never window.open), Settings for persistent key/value prefs, Screen for multi-monitor geometry, Power/System for idle and OS info, Process to spawn CLI helpers.
Queue work where the events can be seen. The PHP process spawned by the shell is the one that can emit UI events. If you run queue:work in a separate process, its Notification::show() calls go nowhere. Either run the worker from NativeAppServiceProvider via Process, or use database queue + a worker spawned as part of the app lifecycle, and accept that desktop apps are single-user: prefer synchronous work plus progress events over heavyweight queue infrastructure.
Data lives in user-space, not the bundle. Point SQLite at a writable per-user directory and run migrate --force on first boot (or ship a seeded DB via a publish step). Everything under base_path() should be treated as read-only in the packaged app — that includes .env, storage/, and any file you'd normally let Laravel write.
Ship updates as a feature, not an afterthought. The updater is built in: bump the version, native:publish to produce release artifacts, and call Updater::checkForUpdates() at boot or from a menu item. Wire it on day one against a staging repo — retrofitting it after users are on unsigned, unversioned builds is painful.
CI matrix is the build system. Builds are per-platform; encode native:build for macos-latest, windows-latest, and ubuntu-latest in GitHub Actions with secrets for signing, and let CI be the only place you produce distributables. Locals builds are for smoke-testing only.
storage/logs/laravel.log; Electron/JS errors go to DevTools (open with a keyboard shortcut or --debug). dd()/dump() output is swallowed by the shell — use Log::info() and watch the log file..env is baked in at build time. Any secret in your environment ships inside the binary. Keep API keys server-side, or gate privileged calls behind your Laravel app's auth; never bundle a secret you wouldn't paste into a public repo.native:serve is hot-reloading against your local dev server and .env; native:build is a frozen snapshot with its own config. Bugs that only appear in builds are usually route caching, .env drift, or absolute URLs — always smoke-test an actual native:build before shipping.native:serve will happily reload a Blade view while silently ignoring your new menu because NativeAppServiceProvider::boot() ran once at launch. Restart the shell before concluding an API doesn't exist.base_path() is writable. The .app bundle and Program Files install dirs are read-only. Redirect database, storage, and any uploaded/exported files to a user-data path; otherwise you get permission errors that only reproduce on other people's machines.nativephp/electron/*.js (main/preload). Those files are regenerated on upgrade — the more you patch, the more painful composer update becomes. Prefer feature requests, a thin ipcRenderer.invoke in preload plus a small PHP wrapper, and a note in the README.composer.json, keep native code isolated in app/Native + one provider, and read the upgrade guide before bumping. The escape hatch is that everything is still Laravel: if a native API regresses, you can often fall back to Shell::openExternal() or a web route while you wait for the fix.How can I help you explore Laravel packages today?