nativephp/laravel
NativePHP for Laravel lets you build native desktop apps with the PHP and Laravel skills you already have, bundling your code into a standalone application for Windows, macOS, and Linux without learning a new language or framework.
NativePHP lets you ship an existing Laravel app as a native desktop application: Electron hosts a window, a bundled PHP binary serves your Laravel routes, and the package exposes facades for OS integration (windows, menus, notifications, dialogs, clipboard, tray, updater).
Minimal path:
composer require nativephp/laravel
composer require nativephp/electron # desktop provider (use nativephp/mobile for mobile)
php artisan native:install # publishes config, installs provider deps/binaries
php artisan native:dev # serve + hot reload
Then open a window. The generated app/Providers/NativeAppServiceProvider.php only boots inside the native runtime — that's the correct place for window/menu setup:
public function boot(): void
{
Window::open()
->route('dashboard')
->title(config('app.name'))
->width(1280)
->height(800)
->minWidth(900)
->rememberState();
}
Where to look first: config/nativephp.php (app id, version, window defaults, queue workers, updater), the generated NativeAppServiceProvider, and the Native\Laravel\Facades\* classes you'll actually call day to day (Window, MenuBar, Notification, Dialog, Shell, System, Settings, Updater).
First real use case: wrap an internal admin/dashboard in a window and delete a class of "open Chrome, log in, find the bookmark" friction — notifications and a tray/menu bar entry point usually justify the build on their own.
Keep the browser experience and the native shell the same app. Every window loads your normal Laravel routes over an internal loopback server, so middleware, auth, Blade/Inertia/Livewire all behave as usual. Build features in the web app; add native only where the OS matters.
Bootstrapping lives in one provider. Menus, shortcuts, window definitions, and event listeners belong in NativeAppServiceProvider::boot(). Treat it as the app's "main" function — don't scatter Window::open() into controllers.
Menus as navigation. MenuBar::create()->label('File')->link('Dashboard', '/dashboard') (or ->route()), ->separator(), ->quit(). Use Menu::create() for the in-window menu and ContextMenu for right-click actions instead of building custom JS menus.
OS integration calls are thin facades — Notification::title()->show(), Dialog::open()/save()/message(), Shell::openExternal(), Shell::showInFolder(), Clipboard::text(), System::theme(), Settings::set()/get(). The JS side mirrors these through the Native global (Native.notification.show(...)), so lightweight interactions don't need a round trip to PHP.
React to events, not polling. Listen for Native\Laravel\Events\WindowShown, WindowClosed, NotificationClicked, MenuItemClicked, SettingChanged, etc. in EventServiceProvider or inline with Event::listen() in the native provider. These are the hooks for "user clicked the tray → show a new window".
Background work needs queue workers. Set config('nativephp.queue_workers') to a non-zero value; the Electron process spawns that many queue:work children. Anything long-running (imports, syncs, PDF generation) should be a job, not a synchronous request — otherwise the window's web process blocks.
Database is a separate SQLite file inside the app data directory, not your repo's database/database.sqlite. Use native:migrate during install/build and native:seed to populate onboarding/admin/demo data into the shipped database. Commands in the native: namespace run against the native app's environment, which is why plain php artisan migrate doesn't move the needle.
Distribution and updates: bump version in config/nativephp.php, configure updater in the same file, and wire Updater::checkForUpdates()/->download() into a "Check for updates" menu item. Ship through your own feed or GitHub Releases; code signing is a separate, non-optional problem (see below).
native:seed was broken in 1.3.0 and is fixed in 1.3.1 (PR #680). If you're on 1.3.0, seeding the native database for demo data, initial admin users, or factory-generated fixtures silently fails or throws. Upgrade, then re-run php artisan native:seed and verify with a native:run query or a settings read. This matters because the native DB is invisible from your dev workflow — you'll otherwise only discover the empty tables after building a distributable..env is not what you think it is at runtime. Only NATIVEPHP_-prefixed variables are baked at build time; don't assume the rest of your dev environment ships. Resolve config at build, and treat secrets as "not present in the app" unless you deliberately handle them.base_path() fail after signing, particularly on macOS where the app bundle is effectively read-only. Use storage_path() (mapped into the app data directory) or the package's File facade; never write next to your source files.native:install overwrites published assets; use --force deliberately, and re-publish after changing the Electron side. Hot reload only exists under native:dev — PHP class changes sometimes need a restart of the command.native:serve/native:dev, Log::info() lands in the terminal. For a shipped build, logs and the SQLite DB are in the app data folder — native:open (or ~/Library/Application Support/{app_id} / %APPDATA% on Windows) is the first thing to check when "it works locally but not in the build."nativephp/actions in GitHub Actions and cache the dependency directories between jobs.rememberState() writes to the app data dir, so "it opens at the wrong size" is a local state problem, not a code problem. Same for Settings.nativephp/electron) is publishable — customize the Electron main process and JS entry there; use the NativeAppServiceProvider for app-level wiring; use config('nativephp.provider') to swap providers if you support both desktop and mobile from one codebase.How can I help you explore Laravel packages today?