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.
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:install2Would 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!
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(): void10 {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.
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(): void2{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(): array2{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(): void2{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/Index2php artisan livewire:make TeamMember/Create3php 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')}} <span15 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 @endforeach28 </div>29</div>
If you have seeded your database locally then previewing this in the browser should look like this:
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.
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 Component10{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's13 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 @enderror21 </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's26 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 @endforeach32 </select>33 @error('timezone')34 <div class="mt-1 text-red-500 text-sm">{{ $message }}</div>35 @enderror36 </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 Team39 Mate40 </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 Component10{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->timezone31 ]);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 @enderror19 </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 @endforeach29 </select>30 @error('timezone')31 <div class="mt-1 text-red-500 text-sm">{{ $message }}</div>32 @enderror33 </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 Team36 Mate37 </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.png2menuBarIconTemplate@2x.png
By adding these icons to the storage/app
directory, and then updating our NativeAppServiceProvider
boot method to:
1public function boot(): void2{3 Menubar::create()->icon(storage_path('app/menuBarIconTemplate.png'));;4}
On the next serve, we should see the icon update in our menu bar.
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!