Build a Spotlight-like App with NativePHP
To the two of you who asked…
The Idea
Sometimes a random idea just pops up in my mind. This time I asked myself whether it's possible to build a Spotlight-like app with NativePHP? Should be pretty simple I thought, and to be honest pretty simple it is.
The Main View
Let's define that we want a menubar app in the NativeAppServiceProvider. We need to position the window in the upper center, hide the dock icon, and keep the window always on top:
MenuBar::create()
->route('launcher')
->width(600)
->height(44)
->upperCenter() // not available yet, use center() instead
->alwaysOnTop()
->resizable(false)
->showDockIcon(false)
->icon(public_path('menuBarIconTemplate.png'));
We also need a global shortcut to toggle our launcher from anywhere. ToggleAppLauncher is a standard event that broadcasts on the nativephp channel:
GlobalShortcut::key('CmdOrCtrl+Shift+A')
->event(ToggleAppLauncher::class)
->register();
And this is how we listen for it in the Livewire component:
#[On('native:' . ToggleAppLauncher::class)]
public function toggle(): void
{
$this->resetState();
MenuBar::show();
}
The Apps
Since I was curious whether it's even possible the first thing I did is just map through installed apps, and since I know where apps live it was a no brainer:
protected array $searchPaths = [
'/Applications',
'/System/Applications',
'/Applications/Setapp',
];
Each .app bundle has an Info.plist with all the metadata we need — name, bundle ID, version, icon file. MacOS ships with plutil which converts plist to JSON:
protected function parsePlist(string $plistPath): array
{
$result = Process::run("plutil -convert json -o - " . escapeshellarg($plistPath));
if ($result->failed()) {
return [];
}
return json_decode($result->output(), true) ?? [];
}
After I was able to get all installed apps I thought that I need to cache those to introduce at least some performance. I've tried to use Cache::flexible since well, why not. However, it was not the best decision since I need to wait for cache to rebuild each time. So, what I really need to know is whether the state of folder(s) where apps live changed. PHP has a function to take advantage of — filemtime. The implementation looks like this:
public function getAll(): Collection
{
$cached = $this->readCacheFile();
if (! $cached) {
return $this->refreshCache();
}
if (($cached['directory_mtimes'] ?? []) !== $this->getDirectoryMtimes()) {
defer(fn () => $this->refreshCache());
}
return collect($cached['apps']);
}
If we have a valid cache we return it. If the cache is stale we still return it, but schedule a refresh with defer() so it happens after the response. No cache at all? Scan synchronously. The cache file looks like this:
{
"generated_at": 1769257802,
"directory_mtimes": {
"\/Applications": 1769254665,
"\/System\/Applications": 1763819368,
"\/Applications\/Setapp": 1769254639
},
"apps": [
{
"name": "1Password",
"path": "\/Applications\/1Password.app",
"bundleId": "com.1password.1password",
"version": "8.12.0",
"iconPath": "\/Applications\/1Password.app\/Contents\/Resources\/icon.icns"
},
{
"name": "Agenda",
"path": "\/Applications\/Agenda.app",
"bundleId": "com.momenta.agenda.macos",
"version": "21.1",
"iconPath": "\/Applications\/Agenda.app\/Contents\/Resources\/AppIcon.icns"
},
...
]
}
Launch Tracking
Once I had search working, I noticed I kept launching the same three (so to speak) apps over and over. So naturally I wanted the most used apps to show first, for example I want to show Notes app first instead of Numbers. A simple app_launches table with an upsert does the trick:
AppLaunch::upsert(
values: ['bundle_id' => $bundleId, 'launch_count' => 1, 'last_launched_at' => now()],
uniqueBy: ['bundle_id'],
update: ['launch_count' => DB::raw('launch_count + 1'), 'last_launched_at' => now()]
);
When searching, results are sorted by launch frequency first, alphabetically second:
return $filtered
->sortByDesc(fn ($app) => $launchCounts[$app['bundleId']] ?? 0)
->values();
The Calculator
One of the most important features of Spotlight for me is the ability to do simple math, so I decided to implement that as well. In the Livewire component the calculator result is a computed property that takes priority over app results:
#[Computed]
public function calculatorResult(): ?string
{
$result = resolve(CalculatorService::class)->evaluate($this->search);
return match (true) {
$result === null => null,
$result == (int) $result => number_format($result),
default => (string) round($result, 10),
};
}
Type 50 / 2, get 25. Hit return and it copies to clipboard. And that's it!
Examples


Afterword
While many features are yet to be implemented to ship the thing, this blog post provides you the bare bones of how you can use NativePHP to extend even the craziest idea of yours.