NativePHP: Build Desktop Apps with Laravel and PHP
Laravel has long been the go‑to framework for building robust web applications, but what if you could take that same expressive syntax and turn it into a native desktop experience? With NativePHP, you can bundle a Laravel codebase into a standalone desktop app that runs on Windows, macOS, and Linux—all without abandoning the PHP ecosystem you already love.
Why Choose NativePHP?
NativePHP bridges the gap between web and desktop by wrapping a lightweight Chromium engine around a Laravel backend. This means you get the full power of Blade, Eloquent, and the Laravel service container, while delivering a native‑look‑feel to end users.
Key benefits include:
- Single codebase: Write once, ship everywhere.
- Zero JavaScript fatigue: Your UI lives in Blade templates, not in a separate SPA.
- Native OS features: Access file system dialogs, system notifications, and tray icons directly from PHP.
Getting Started: Installing NativePHP
The installation process mirrors a typical Laravel setup. First, ensure you have Composer and Node.js installed, then add the NativePHP package.
composer require nativephp/nativephp --dev
npm install -g nativephp-cli
After the packages are in place, initialize the desktop scaffolding:
php artisan nativephp:install
This command creates a nativephp directory containing the Electron‑based runtime, a nativephp.config.js file for build settings, and a desktop folder where you’ll place any OS‑specific assets.
Configuring the Build
Open nativephp.config.js and tweak the following options to match your project’s branding:
module.exports = {
appName: 'MyLaravelDesk',
icon: './desktop/icon.png',
platforms: ['win32', 'darwin', 'linux'],
// Enable auto‑update checks
autoUpdate: true,
};
When you’re ready, run the build command:
nativephp build
The CLI compiles the Laravel server, bundles the Chromium wrapper, and outputs platform‑specific installers in the dist folder.
Architectural Overview
Under the hood, NativePHP launches two processes:
- PHP Backend – A built‑in PHP server (powered by
php -S) serves your Laravel routes on a random localhost port. - Electron Frontend – A minimal Electron app loads
http://localhost:{port}inside a Chromium window.
This separation lets you keep all Laravel middleware, authentication, and queue logic untouched. The Electron layer merely acts as a thin client, translating native OS events into HTTP requests that Laravel can handle.
Communication Between Frontend and Backend
NativePHP exposes a JavaScript bridge called window.nativephp. You can call PHP routes directly from the renderer process:
window.nativephp.invoke('POST', '/api/tasks', { title: 'Buy milk' })
.then(response => console.log(response))
.catch(err => console.error(err));
The bridge automatically includes CSRF tokens and session cookies, so you don’t have to manage authentication manually.
Pro tip: Use Laravel Sanctum for API token management if you plan to expose the same backend to a web client. NativePHP will forward the token header without extra code.
Building a Real‑World Desktop App: Task Manager Example
Let’s walk through a practical example—a simple task manager that runs offline, syncs to a remote API when the internet is available, and leverages native notifications.
Step 1: Define the Data Model
Start with a standard Eloquent model and migration.
php artisan make:model Task -m
Edit the migration (database/migrations/xxxx_create_tasks_table.php) to include the fields you need:
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->boolean('completed')->default(false);
$table->timestamps();
});
Run the migration:
php artisan migrate
Step 2: Create a Blade‑Based UI
Because NativePHP serves your Laravel routes, you can use Blade just like on the web. Create a view at resources/views/desktop/tasks.blade.php:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Task Manager</title>
<link rel="stylesheet" href="{{ mix('css/app.css') }}">
</head>
<body class="p-4">
<h1 class="text-2xl mb-4">My Tasks</h1>
<form id="new-task" class="flex mb-4">
<input type="text" name="title" placeholder="New task..."
class="flex-1 border rounded p-2">
<button type="submit" class="ml-2 bg-blue-500 text-white p-2 rounded">Add</button>
</form>
<ul id="task-list">
@foreach($tasks as $task)
<li data-id="{{ $task->id }}" class="flex items-center mb-2">
<input type="checkbox" {{ $task->completed ? 'checked' : '' }}
class="mr-2 task-toggle">
<span class="{{ $task->completed ? 'line-through' : '' }}">
{{ $task->title }}
</span>
</li>
@endforeach
</ul>
<script src="{{ mix('js/app.js') }}"></script>
</body>
</html>
The view pulls a $tasks collection from the controller, which we’ll define next.
Step 3: Controller Logic
Create a controller that serves the desktop view and handles JSON API calls.
php artisan make:controller Desktop/TaskController
Inside app/Http/Controllers/Desktop/TaskController.php:
namespace App\Http\Controllers\Desktop;
use App\Http\Controllers\Controller;
use App\Models\Task;
use Illuminate\Http\Request;
class TaskController extends Controller
{
public function index()
{
$tasks = Task::orderBy('created_at', 'desc')->get();
return view('desktop.tasks', compact('tasks'));
}
public function store(Request $request)
{
$task = Task::create($request->only('title'));
return response()->json($task);
}
public function toggle(Task $task)
{
$task->completed = ! $task->completed;
$task->save();
return response()->json($task);
}
}
Register the routes in routes/web.php (or a dedicated desktop.php file):
Route::prefix('desktop')
->middleware('web')
->group(function () {
Route::get('tasks', [\App\Http\Controllers\Desktop\TaskController::class, 'index']);
Route::post('tasks', [\App\Http\Controllers\Desktop\TaskController::class, 'store']);
Route::post('tasks/{task}/toggle', [\App\Http\Controllers\Desktop\TaskController::class, 'toggle']);
});
Step 4: Wire Up the Frontend with NativePHP Bridge
In resources/js/app.js, use the bridge to call the API without leaving the desktop context.
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('new-task');
const list = document.getElementById('task-list');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const title = form.title.value.trim();
if (!title) return;
const task = await window.nativephp.invoke('POST', '/desktop/tasks', { title });
addTaskToList(task);
form.reset();
});
list.addEventListener('change', async (e) => {
if (!e.target.classList.contains('task-toggle')) return;
const li = e.target.closest('li');
const id = li.dataset.id;
const updated = await window.nativephp.invoke('POST', `/desktop/tasks/${id}/toggle`);
li.querySelector('span').classList.toggle('line-through', updated.completed);
});
});
function addTaskToList(task) {
const li = document.createElement('li');
li.dataset.id = task.id;
li.className = 'flex items-center mb-2';
li.innerHTML = `
${task.title}
`;
document.getElementById('task-list').prepend(li);
}
Because the bridge runs inside Electron, the UI feels instant, and the PHP backend persists data locally in SQLite (or any DB you configure).
Step 5: Adding Native Notifications
NativePHP exposes the Electron Notification API through a helper method. Create a small service class:
namespace App\Services;
class DesktopNotifier
{
public static function push(string $title, string $body)
{
$script = <<
Now, whenever a task is marked completed, trigger a notification:
public function toggle(Task $task)
{
$task->completed = ! $task->completed;
$task->save();
if ($task->completed) {
\App\Services\DesktopNotifier::push('Task Completed', $task->title);
}
return response()->json($task);
}
Pro tip: Batch notifications when the app goes offline. Store pending messages in a local table and flush them once the network is restored.
Beyond Simple Apps: Real‑World Use Cases
1. Point‑of‑Sale (POS) Systems – Retail operators can run a Laravel‑driven POS on a locked‑down Windows machine, leveraging barcode scanner input via native USB hooks provided by NativePHP.
2. Internal Dashboards – Companies often need a secure, offline‑first analytics tool. Bundle your existing Laravel reporting routes into a desktop client, and use the built‑in SQLite sync to push data to a central server nightly.
3. Media Management – Photo or video editors can benefit from Laravel’s file handling APIs while enjoying drag‑and‑drop support from the OS, all wrapped in a single binary.
Integrating with System Tray
Many desktop utilities live in the system tray. NativePHP makes this trivial:
\NativePHP\Tray::create([
'icon' => resource_path('icon.png'),
'title' => 'Task Manager',
'menu' => [
['label' => 'Show', 'action' => 'showWindow'],
['label' => 'Quit', 'action' => 'quitApp'],
],
]);
The showWindow action can be bound to a PHP method that calls \NativePHP\Window::show();. This pattern keeps all UI logic inside Laravel, avoiding separate Electron codebases.
Testing & Debugging
NativePHP ships with a hot‑reload mode that watches both PHP files and front‑end assets. Run:
nativephp serve --watch
The CLI prints the local port and opens a Chrome DevTools window automatically. You can set breakpoints in your Blade templates, inspect network calls, and even debug PHP with Xdebug attached to the internal server.
For unit testing, continue using Laravel’s built‑in PHPUnit suite. The desktop layer does not interfere with backend logic, so your existing tests remain valid.
Pro tip: Write integration tests that spin up a temporary NativePHP instance using proc_open. Assert that the Electron window loads the expected URL and that IPC messages are exchanged correctly.
Packaging for Production
When you’re ready to ship, the nativephp build command creates signed installers for each platform. Customize the installer UI by editing the nativephp/installer folder – you can add a license agreement, custom splash screens, or even an auto‑update server endpoint.
To enable auto‑updates, point the autoUpdateUrl field in nativephp.config.js to a JSON manifest that lists the latest version and download URLs. NativePHP will check this endpoint on startup and prompt users to install updates seamlessly.
Performance Considerations
Because the app runs a full Laravel stack, memory usage is higher than a native C++ program. However, you can mitigate this by:
- Using
php artisan optimizeto cache routes and config. - Enabling OPcache in the bundled PHP binary.
- Limiting the number of background workers—most desktop apps need only one request thread.
Electron’s Chromium engine is already optimized for modern hardware, and the overhead becomes negligible on machines with 4 GB+ RAM.
Security Best Practices
Even though the app runs locally, treat it like a web service:
- Keep CSRF protection enabled; the bridge automatically adds the token.
- Store sensitive data (API keys, passwords) in the OS keychain using the
nativephp-keychainhelper. - Sign your binaries with a code‑signing certificate to prevent tampering.
If your desktop app communicates with a remote API, enforce TLS and validate server certificates. NativePHP’s bridge respects the system’s trust store, so you don’t need extra configuration.
Advanced: Extending NativePHP with Custom Native Modules
Sometimes you need functionality that isn’t exposed out of the box—think Bluetooth, custom hardware, or low‑level audio processing. NativePHP allows you to write native Node.js modules and expose them to PHP via a simple RPC layer.
// my-module/index.js
const { ipcMain } = require('electron');
ipcMain.handle('native:get-battery', async () => {
const battery = await require('systeminformation').battery();
return battery.percent;
});
Register the module in nativephp.config.js:
module.exports = {
// …
extraModules: ['./my-module'],
};
Now call it from PHP:
use NativePHP\Bridge;
$percent = Bridge::invoke('native:get-battery');
echo "Battery level: {$percent}%";
This pattern gives you the flexibility