Published in Career

NativePHP for Beginners

NativePHP allows you to choose from two different popular technologies to use under the hood, Electron, and Tauri. They both allow you to "Build cross-platform desktop apps with JavaScript, HTML, and CSS".

By Shane Rosenthal

Unless you have been living under a rock for the last couple of weeks, you are probably already familiar with NativePHP. NativePHP, created by Marcel Pociot at BeyondCode, allows us Laravel Devs to leverage ALL the working knowledge we already have with Laravel to build native Mac, Windows, and Linux applications.

I recently saw Christopher Rumpel working on an app that let's you store the timezones of your friends so you can see what time it is at a glance. Follow along with me as we put together a Mac MenuBar application to know the local time of each member of your team.

tldr;

Click to watch the tutorial now. Thumbnail

Wait - how does this even work??

NativePHP allows you to choose from two different popular technologies to use under the hood, Electron, and Tauri. They both allow you to "Build cross-platform desktop apps with JavaScript, HTML, and CSS". This is kinda like sorcery if you think about it - web technologies to build out a 'native' application. NativePHP provides a simple API with a familiar ( Laravel) way to build out applications in either of these underlying technologies. For this example, I will be demonstrating the Electron wrapper.

Installation and Hello World

In a fresh Laravel application:

1laravel new team-time

Let's start with installing the package:

1composer require nativephp/electron

Run the installer:

1php artisan native:install
2Would you like to install the NativePHP NPM dependencies? - Select 'yes'
3Would you like to start the NativePHP development server? - Select 'no'

I want you to manually start the application so you get used to doing it this way:

1php artisan native:serve

After a moment you should see a native desktop application spin up displaying the default Laravel homepage, hello there! Hello World

Show me the code!

Sure, but settle down a bit, everything will be revealed shortly. Navigate over to App\Providers\NativeAppServiceProvider.php. Here you can see some of the NativePHP API stubbed out for you. For this example, we are not going to use this code though. Go ahead and clear out everything in the boot method and replace it with the following:

1<?php
2 
3namespace App\Providers;
4 
5use Native\Laravel\Facades\MenuBar;
6 
7class NativeAppServiceProvider
8{
9 public function boot(): void
10 {
11 Menubar::create();
12 }
13}

Since NativePHP does hot reloading we should see the Window close and a Menubar icon appear at the top of your computer. Clicking on it will reveal the same default Laravel home page. Menubar

Nice! Let's build something cool!

Behind the scenes, I am installing TailwindCSS per their docs , Laravel Livewire 3 (dicey, I know but it's my drug of choice) , Blade Heroicons and then adding our TeamMember model, migration and factory with the following command:

1php artisan make:model TeamMember -mf
1NOTE: I am keeping `npm run dev` running for hot reloading of the ui.

Migration:

1public function up(): void
2{
3 Schema::create('team_members', function (Blueprint $table) {
4 $table->id();
5 $table->string('name');
6 $table->string('timezone');
7 $table->timestamps();
8 });
9}

Factory

1public function definition(): array
2{
3 return [
4 'name' => $this->faker->name,
5 'timezone' => $this->faker->randomElement(timezone_identifiers_list())
6 ];
7}

Then updating my App\Database\seeders\DatabaseSeeder.php to:

1public function run(): void
2{
3 \App\Models\TeamMember::factory(10)->create();
4}

And running php artisan migrate and php artisan db:seed.

1NOTE: The application inside of NativePHP does NOT have access to the database defined in your `.env`. From my experience, it can be useful to seed your database locally and debug in the browser or by using Spatie/Ray.

Let's Create Our Livewire Classes and Views

1php artisan livewire:make TeamMember/Index
2php artisan livewire:make TeamMember/Create
3php artisan livewire:make TeamMember/Update

Then update our web.php to the following:

1Route::get('/', \App\Livewire\TeamMember\Index::class)->name('index');
2Route::get('/team-members/create', \App\Livewire\TeamMember\Create::class)->name('create');
3Route::get('/team-members/{teamMember}/edit', \App\Livewire\TeamMember\Update::class)->name('edit');

And create an app.blade.php inside of resources/views/components/layouts with the following html :

1<!DOCTYPE html>
2<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
3<head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 
7 <title>Laravel</title>
8 @vite('resources/css/app.css')
9</head>
10<body class="antialiased bg-gray-900 text-gray-100">
11<div class="max-w-md mx-auto px-4 py-6">
12 {{$slot}}
13</div>
14</body>
15</html>

Listing Our Teammates

Inside the App\Livewire\TeamMember\Index class we need to fetch all of the team members to display them, additionally, we should offer a link to create a new team member and offer update and delete buttons for existing team members.

Class:

1<?php
2 
3namespace App\Livewire\TeamMember;
4 
5use App\Models\TeamMember;
6use Livewire\Component;
7 
8class Index extends Component
9{
10 public function deleteMember(TeamMember $member)
11 {
12 $member->delete();
13 }
14 public function render()
15 {
16 $team = TeamMember::get();
17 return view('livewire.team-member.index', compact('team'));
18 }
19}

View:

1 
2<div>
3 <div class="flex items-center justify-between mb-10">
4 <h1 class="text-xl font-bold">My Team</h1>
5 <a href="{{route('create')}}" type="button"
6 class="rounded-full bg-pink-600 px-2 py-1 text-xs font-bold text-white shadow hover:bg-pink-500">Add Team
7 Mate</a>
8 </div>
9 <div wire:poll>
10 @foreach($team as $member)
11 <div wire:key="{{ $member->id }}" class="my-2 flex items-center justify-between">
12 <div>
13 <p class="text-xs font-bold text-sky-500">{{$member->name}}</p>
14 <p class="text-lg">{{now()->tz($member->timezone)->format('h:i:s A')}} <span
15 class="text-xs text-gray-500">- {{$member->timezone}}</span></p>
16 </div>
17 <div class="flex items-center">
18 <a href="{{route('edit', ['team-member' => $member])}}">
19 <span class="sr-only">Edit</span>
20 <x-heroicon-m-pencil class="w-5 h-5 mr-3 hover:text-pink-500 transition-all duration-300" />
21 </a>
22 <button wire:click="deleteMember({{$member}})">
23 <x-heroicon-m-trash class="w-5 h-5 mr-3 hover:text-red-600 transition-all duration-300" />
24 </button>
25 </div>
26 </div>
27 @endforeach
28 </div>
29</div>

If you have seeded your database locally then previewing this in the browser should look like this: Index

In the native app it should look like this since we don't have any data there yet (make sure to run npm run build then php artisan native:serve). NativePHP uses a local SQLite database behind the scenes, we don't need any additional setup or configuration for it. Index Native Now let's handle the Create operations, so we can see this in the native app too.

Class:

1<?php
2 
3namespace App\Livewire\TeamMember;
4 
5use App\Models\TeamMember;
6use Livewire\Attributes\Rule;
7use Livewire\Component;
8 
9class Create extends Component
10{
11 #[Rule(['required', 'string', 'min:3'])]
12 public string $name;
13 
14 #[Rule(['required', 'string', 'min:3'])]
15 public string $timezone;
16 
17 public function createMember()
18 {
19 TeamMember::create($this->validate());
20 $this->redirectRoute('index');
21 }
22 
23 public function render()
24 {
25 return view('livewire.team-member.create');
26 }
27}

View:

1 
2<div>
3 <div class="flex items-center justify-between mb-10">
4 <h1 class="text-xl font-bold">Add Team Member</h1>
5 <a href="{{route('index')}}" type="button"
6 class="rounded-full bg-pink-600 px-2 py-1 text-xs font-bold text-white shadow hover:bg-pink-500 flex items-center">
7 Go Back
8 </a>
9 </div>
10 <form wire:submit="createMember">
11 <div>
12 <label for="name" class="block text-sm font-medium leading-6 text-gray-100">What is your team member's
13 name?</label>
14 <div class="mt-2">
15 <input type="text" wire:model="name" id="name"
16 class="block w-full rounded-md border-0 py-1.5 text-gray-400 shadow-sm bg-gray-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-pink-600 sm:text-sm sm:leading-6"
17 placeholder="Sarthak">
18 @error('name')
19 <div class="mt-1 text-red-500 text-sm">{{ $message }}</div>
20 @enderror
21 </div>
22 </div>
23 
24 <div class="mt-6">
25 <label for="timezone" class="block text-sm font-medium leading-6 text-gray-100">What is your team member's
26 timezone</label>
27 <select id="timezone" wire:model="timezone"
28 class="mt-2 block w-full rounded-md border-0 py-1.5 text-gray-400 shadow-sm bg-gray-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-pink-600 sm:text-sm sm:leading-6">
29 @foreach(timezone_identifiers_list() as $timezone)
30 <option wire:key="{{ $timezone }}">{{$timezone}}</option>
31 @endforeach
32 </select>
33 @error('timezone')
34 <div class="mt-1 text-red-500 text-sm">{{ $message }}</div>
35 @enderror
36 </div>
37 <button type="submit"
38 class="mt-6 rounded bg-pink-600 px-2 py-1 font-bold text-white shadow hover:bg-pink-500 w-full">Add Team
39 Mate
40 </button>
41 </form>
42</div>

Now we're cookin'! But it looks like I set Sarthak to the wrong timezone, let's set up our Edit class and view and put this puppy to sleep.

Class:

1<?php
2 
3namespace App\Livewire\TeamMember;
4 
5use App\Models\TeamMember;
6use Livewire\Component;
7use Livewire\Features\SupportValidation\Rule;
8 
9class Update extends Component
10{
11 public TeamMember $teamMember;
12 
13 #[Rule(['required','min:3', 'string'])]
14 public $name;
15 
16 #[Rule(['required','string'])]
17 public $timezone;
18 
19 public function mount(TeamMember $teamMember)
20 {
21 $this->teamMember = $teamMember;
22 $this->name = $teamMember->name;
23 $this->timezone = $teamMember->timezone;
24 }
25 
26 public function saveMember()
27 {
28 $this->teamMember->update([
29 'name' => $this->name,
30 'timezone' => $this->timezone
31 ]);
32 
33 $this->redirectRoute('index');
34 }
35 
36 public function render()
37 {
38 return view('livewire.team-member.update');
39 }
40}

View:

1<div>
2 <div class="flex items-center justify-between mb-10">
3 <h1 class="text-xl font-bold">Update Team Member</h1>
4 <a href="{{route('index')}}" type="button"
5 class="rounded-full bg-pink-600 px-2 py-1 text-xs font-bold text-white shadow hover:bg-pink-500 flex items-center">
6 Go Back
7 </a>
8 </div>
9 <form wire:submit="saveMember">
10 <div>
11 <label for="name" class="block text-sm font-medium leading-6 text-gray-100">Name</label>
12 <div class="mt-2">
13 <input type="text" wire:model.blur="name" id="name"
14 class="block w-full rounded-md border-0 py-1.5 text-gray-200 shadow-sm bg-gray-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-pink-600 sm:text-sm sm:leading-6"
15 placeholder="Sarthak">
16 @error('name')
17 <div class="mt-1 text-red-500 text-sm">{{ $message }}</div>
18 @enderror
19 </div>
20 </div>
21 
22 <div class="mt-6">
23 <label for="timezone" class="block text-sm font-medium leading-6 text-gray-100">Timezone</label>
24 <select id="timezone" wire:model="timezone"
25 class="mt-2 block w-full rounded-md border-0 py-1.5 text-gray-200 shadow-sm bg-gray-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-pink-600 sm:text-sm sm:leading-6">
26 @foreach(timezone_identifiers_list() as $timezone)
27 <option {{$teamMember->timezone === $timezone ? 'selected' : ''}}>{{$timezone}}</option>
28 @endforeach
29 </select>
30 @error('timezone')
31 <div class="mt-1 text-red-500 text-sm">{{ $message }}</div>
32 @enderror
33 </div>
34 <button type="submit"
35 class="mt-6 rounded bg-pink-600 px-2 py-1 font-bold text-white shadow hover:bg-pink-500 w-full">Add Team
36 Mate
37 </button>
38 </form>
39</div>

Wrapping It Up

Now that the app is working and looking the way we want it, let's do just a couple more things before building it out. First, let's update the MenuBar icons. I created 2 images, one is a 22x22 png and the other is a 44x44 png. By suffixing the name of these files with the word Template we get some nice functionality. On a Mac, NativePHP will convert these images to a white icon with transparency so that it matches the color scheme of the native menu bar.

The two images are named:

1menuBarIconTemplate.png
2menuBarIconTemplate@2x.png

By adding these icons to the storage/app directory, and then updating our NativeAppServiceProvider boot method to:

1public function boot(): void
2{
3 Menubar::create()->icon(storage_path('app/menuBarIconTemplate.png'));;
4}

On the next serve, we should see the icon update in our menu bar.

Updated Icon

Last, let's add some items to our .env file to tell NativePHP some details about our app:

1NATIVEPHP_APP_NAME="TeamTime"
2NATIVEPHP_APP_VERSION="1.0.0"
3NATIVEPHP_APP_ID="com.teamtime.desktop"
4NATIVEPHP_DEEPLINK_SCHEME="teamtime"
5NATIVEPHP_APP_AUTHOR="Shane D Rosenthal"
6NATIVEPHP_UPDATER_ENABLED=false

Build

1php artisan native:build

Running this command will package everything up that we need to build the app locally and give us a native file (' .dmg', '.exe', etc.). Once complete, the files will be placed in your project's root/dist directory and you can distribute the app as you see fit.

1As of the time of this writing, the php artisan native:build function works, however when I open the .dmg locally it sort of 'hangs' and my menu bar application doesn't start. Again, NativePHP is still currently in an `alpha` state and problems are expected, the BeyondCode team is hard at work fixing items like this and we should expect full functionality in the weeks or months to come.

Summary

Well, what did you think? Pretty awesome that we can build native apps with Laravel, right? I can think of a lot of use cases for such a feature and I can't wait to continue exploring and seeing Laravel get pushed to new heights. There are so many other items in the NativePHP docs that this app doesn't cover or go over, take a look yourself, get inspired, and build something awesome. #laravelforever!