Originally published at hafiz.dev
Every developer has ignored a break reminder. The notification pops up, you dismiss it in 0.2 seconds without thinking, and two hours later your back hurts and your eyes ache. Notifications don't work. You need something you can't mindlessly dismiss.
So I built ForcedBreak. A macOS menu bar app that shows a full-screen overlay after a configurable interval (25, 45, 60 minutes, whatever you prefer) and makes you complete a physical challenge before you can get back to work. Push-ups. A glass of water. Box breathing. It covers all your monitors. You have to consciously deal with it: complete the challenge, skip it (with a 5-minute penalty), or close it and feel bad about yourself.
The interesting part isn't the concept. It's that I built it entirely with PHP: NativePHP, Laravel 12, Livewire 4, and Tailwind. No Swift. No Objective-C. No raw Electron boilerplate. The app is open source on GitHub and the .dmg for Apple Silicon is available on the releases page.
This post covers the architecture and the four problems that gave me real trouble. If you're building anything with NativePHP, you'll hit at least two of these.
Why NativePHP and Why Laravel 12, Not 13
NativePHP wraps your Laravel app in Electron, giving you access to native macOS APIs through Laravel facades: menu bar, system notifications, window management, screen info. You write PHP and Blade. NativePHP handles the Electron layer.
If you want a proper NativePHP introduction first, I covered the setup basics in Build Your First App with Laravel and NativePHP. This post assumes you have a working NativePHP setup and focuses on the harder parts.
The stack for ForcedBreak:
| Layer | Choice | Why |
|---|---|---|
| Desktop framework | NativePHP 1.3 | Laravel-native desktop apps, no Swift needed |
| Backend | Laravel 12 | Familiar cache, ORM, everything |
| UI | Livewire 4 | Reactive components without writing JavaScript |
| Styling | Tailwind CSS v4 | Dark theme, fast iteration |
| Database | SQLite | Fully offline, ships with the app |
One critical note on the version: NativePHP 1.x requires illuminate/contracts ^10.0|^11.0|^12.0. Laravel 13 internalized illuminate/contracts as part of the framework itself, which breaks this Composer constraint. You can't use Laravel 13 with NativePHP 1.x. Pinning to Laravel 12 is the right call until NativePHP officially ships support.
The Core Architecture: Two Timers, Not One
This is the most important thing to understand before building any NativePHP app that needs background behavior.
The obvious approach is Livewire's wire:poll. Set it to tick every second, decrement a counter, show the result in the menu bar. Simple enough.
It doesn't work. Livewire polling only runs when a browser window is open. A menu bar app lives in the menu bar. The popover window is closed 99% of the time. When the user isn't looking at the popover, wire:poll isn't running. The timer would only tick when the user clicked to open it.
The solution is two separate systems:
Layer 1: The background ticker (authoritative). A dedicated Artisan command runs in an infinite loop as a persistent ChildProcess. It ticks every second, decrements the cache, updates the menu bar label, and triggers the overlay when the timer hits zero. This runs whether the popover is open or not.
Layer 2: Livewire polling (UI only). When the user opens the popover, wire:poll.1000ms reads from the same cache keys and displays the countdown. It never writes to cache. It's a pure reader.
// app/Console/Commands/TickMenuBarLabel.php
public function handle(): void
{
$lastLabel = '';
while (true) {
$start = microtime(true);
if (!cache()->get('on_break', false)) {
$secondsLeft = max(0, cache()->get('break_seconds_left', 0) - 1);
cache()->put('break_seconds_left', $secondsLeft, now()->addHours(2));
// Only call MenuBar::label() when the value actually changes
$label = sprintf('%02d:%02d', intdiv($secondsLeft, 60), $secondsLeft % 60);
if ($label !== $lastLabel) {
MenuBar::label($label);
$lastLabel = $label;
}
if ($secondsLeft <= 0) {
$this->openOverlayOnAllScreens();
}
}
$this->sleepUntilNextSecond($start);
}
}
protected function sleepUntilNextSecond(float $start): void
{
$elapsed = microtime(true) - $start;
$remaining = max(0.1, 1.0 - $elapsed);
usleep((int) ($remaining * 1_000_000));
}
This is registered in NativeAppServiceProvider:
ChildProcess::artisan('app:tick-menubar-label', 'ticker', persistent: true);
The sleepUntilNextSecond() method deserves a mention because the naive version of this loop (just calling sleep(1)) causes 100% CPU usage. sleep(1) blocks for exactly one second but ignores the time the tick itself took. Over time the loop drifts, and under some system conditions it spins. The fix is to measure how long the tick took with microtime(true) and sleep only the remaining microseconds to the next second boundary. It also skips calling MenuBar::label() when the value hasn't changed, which avoids unnecessary HTTP calls to Electron's bridge on every tick.
The persistent: true flag tells NativePHP to restart the process if it crashes. Without it, the ticker dies and the menu bar freezes.
If you've worked with Laravel queue workers running as background daemons, this pattern will feel familiar. The mental model is the same: one authoritative process manages state, and the UI reads from it. The difference here is that the "worker" is an infinite loop command rather than a Horizon worker, because NativePHP's scheduler runs every minute by default and a one-minute resolution isn't good enough for a visible countdown timer.
The reason persistent: true matters is that an infinite loop command can crash. SQLite can throw an exception, a cache operation can fail, or the process can be killed by macOS under memory pressure. Without persistent: true, your menu bar label freezes at whatever time it was showing when the crash happened, and nothing ever triggers the overlay. The user just sits there wondering why the app stopped working. With persistent: true, NativePHP restarts the child process automatically within a few seconds and the timer continues.
This two-layer pattern applies to anything that needs to run when no window is open: timers, background polling, file watchers, scheduled sync tasks. Once you have it working for one thing, you understand the whole model.
Problem 1: The Dual-Database Trap
This one cost me two hours. After I renamed the app, the timer stopped working entirely. No countdown. No overlay. No errors in the logs. Everything appeared fine when I opened the popover.
Here's what was happening.
NativePHP stores its database at:
~/Library/Application Support/{NATIVEPHP_APP_ID}-dev/database/database.sqlite
When I changed NATIVEPHP_APP_ID in .env, NativePHP created a new storage directory with a fresh database file. The old migrations stayed in the old directory. The new database existed at 0 bytes. All cache()->get() and cache()->put() calls silently returned null. The ticker decremented nothing. Nothing happened.
There's a second layer to this: NativePHP doesn't auto-run migrations in dev mode. The file exists but has no tables.
The fix: auto-migrate on every boot in NativeAppServiceProvider:
public function boot(): void
{
// Auto-migrate ensures the NativePHP database always has tables.
// Without this, a fresh storage directory silently breaks everything.
Artisan::call('migrate', ['--force' => true]);
Artisan::call('db:seed', [
'--class' => 'ChallengesSeeder',
'--force' => true,
]);
// ... rest of boot
}
The seeder uses firstOrCreate, so re-running it on every boot is safe.
The broader rule: never change NATIVEPHP_APP_ID without understanding what it does. Your display name is controlled by NATIVEPHP_APP_NAME and can change freely. The app ID determines the storage directory path. Change it and you lose all settings and user data.
To debug this yourself:
sqlite3 ~/Library/Application\ Support/{your-app-id}-dev/database/database.sqlite ".tables"
If you get no output, the database is empty. Copy your local one across and restart.
Problem 2: Not All NativePHP Facades Work From Child Processes
When I added pre-break warning notifications, the code ran without errors. No notification ever appeared.
NativePHP facades communicate with Electron's main process via an HTTP bridge. Some of them work fine from child processes. Others silently fail.
Here's what I found through testing:
| Facade | Works from ChildProcess? |
|---|---|
MenuBar::label() |
Yes |
Window::open() |
Yes (with a URL caveat, see next section) |
Screen::displays() |
Yes |
Notification::show() |
No, silently fails |
The Notification facade doesn't work from child processes. I tried routing it through a web endpoint as a workaround, where the child process calls an internal HTTP endpoint and that endpoint sends the notification. That also didn't work reliably in my testing.
The pattern I'd suggest before building anything complex with a NativePHP facade: write a quick web route that calls it directly and test in the browser first. If it works there, it will probably work from a child process. If it doesn't work in the browser context, you have a different problem. And if it works in the browser but not in a child process, you've hit this limitation and you'll need to find an alternative approach.
Pre-break notifications are on the v2 list. Once I have more time to investigate the bridge behavior for Notification, I'll add it back in.
Problem 3: URL Resolution in Artisan Context
Window::open() works from child processes, but you have to build the URL yourself.
The default APP_URL in Laravel's .env is http://localhost. NativePHP's PHP server runs on http://127.0.0.1:{dynamic_port}. When the ticker calls Window::open()->route('break.overlay'), it generates http://localhost/break-overlay. Electron tries to load it and gets "Not Found."
The fix: write the actual server URL to a file during boot, then read it in child processes.
// NativeAppServiceProvider::boot()
if (!is_dir(storage_path('app'))) {
mkdir(storage_path('app'), 0755, true);
}
$port = $_SERVER['SERVER_PORT'] ?? 8100;
file_put_contents(
storage_path('app/server_url.txt'),
"http://127.0.0.1:{$port}"
);
Then in the ticker command:
$baseUrl = trim(file_get_contents(storage_path('app/server_url.txt')));
Window::open('break-overlay')->url($baseUrl . '/break-overlay');
Not the most elegant solution, but it works reliably. Since NativeAppServiceProvider::boot() runs before any child process starts, the file is always there when the ticker needs it.
Problem 4: Multi-Screen Overlay
A single Window::open() call opens one window on your primary display. If you have a second monitor, the user can just look over there and keep working.
Screen::displays() returns all connected displays with their bounds. Open one window per display:
$displays = Screen::displays();
foreach ($displays as $i => $display) {
$bounds = $display['bounds'];
$windowId = $i === 0 ? 'break-overlay' : "break-overlay-{$i}";
Window::open($windowId)
->url($baseUrl . '/break-overlay')
->alwaysOnTop()
->titleBarHidden()
->position($bounds['x'], $bounds['y'])
->width($bounds['width'])
->height($bounds['height']);
}
One important gotcha: don't use ->closable(false) on overlay windows. It looks like the right way to prevent accidental dismissal, but it makes Window::close() a no-op. NativePHP calls window.close() under the hood, and when closable is false, Electron ignores it. You'd never be able to close the overlay programmatically.
alwaysOnTop() with titleBarHidden() is enough. The macOS close button is still technically visible, but the user has to make a deliberate choice to click it. That's a different thing from mindlessly swiping away a notification. When the user clicks "I Did It!", the component iterates the same window IDs and closes each one.
Distributing the App
Distributing a NativePHP app outside the Mac App Store is simpler than it sounds. No developer certificate required for direct distribution.
php artisan native:build mac
That produces a .dmg in the dist/ folder. I wrapped this in a build.sh script that handles switching .env to production settings, clearing caches, building assets with npm, running the NativePHP build command, and restoring the dev environment afterward. The whole process takes about 3 minutes on an M2 Mac.
The only thing users have to do after dragging the app to /Applications is run:
xattr -cr /Applications/ForcedBreak.app
This removes the macOS quarantine flag that blocks unsigned apps. It's a standard step for indie Mac apps distributed outside the App Store, safe to run, and you only need to do it once.
Desktop deployment is a different mental model from web apps. There's no server, no zero-downtime concern, no rollback mechanism. You build the .dmg, upload it to GitHub Releases, and users download the new version manually. Much simpler than the deploy pipeline I covered with Scotty and Laravel Envoy, at least for v1.
What I Shipped vs What I Cut
Six features were planned and cut before v1:
| Cut feature | Reason |
|---|---|
| Pre-break notifications |
Notification facade fails from child processes |
| Auto-updater | Adds complexity, manual download is fine for now |
| Onboarding flow | App is self-explanatory |
| Stats and history charts | Nice to have, not essential for the core concept |
| Break snooze | Undermines the "forced" nature of the app |
| iCloud sync | Contradicts the fully offline goal |
Every cut made the app ship faster and the core experience sharper. ForcedBreak does one thing: it covers your screens and makes you do a push-up. Everything else is noise until the core concept is proven.
This is the same thing I try to apply when building MVPs for clients. You can read more about how to validate an idea before spending thousands on development, but the short version is: ship the smallest version that tests the assumption. Features can always be added once someone is using it.
What's Next
ForcedBreak v1.0.0 is live now. If you're on an Apple Silicon Mac and want to try it:
Download ForcedBreak v1.0.0 for Apple Silicon →
The source code is on GitHub under MIT. If you find it useful, a star helps others discover it.
For v2, the list is short: get Notification working before breaks (still investigating the child process bridge), add a streak history chart, and build an Intel (x64) version for older Macs.
If you're debugging a stuck NativePHP app, this is the workflow that solved most of my problems:
- Check the NativePHP database has tables:
sqlite3 ~/Library/Application\ Support/{app-id}-dev/database/database.sqlite ".tables" - Test any NativePHP facade from a web route before using it in a child process
- Use
curl http://127.0.0.1:8100/dev/force-breakto trigger events in the live app, neverphp artisan tinker - Confirm
storage/app/server_url.txtexists and has the correct port
FAQ
Does ForcedBreak work on Intel Macs?
Not yet. The current .dmg is arm64 only (Apple Silicon). An x64 build is on the v2 roadmap.
Can NativePHP build Windows apps?
NativePHP 1.x supports macOS and Linux via Electron. Windows support exists but is less tested in the community. The facades and APIs work the same way in theory.
Why not just build this in Swift?
If you already know Swift, that's valid. NativePHP's advantage is zero context switching. You stay in Laravel, use Eloquent, use the cache, write Blade. For a PHP developer who wants to ship a real desktop app without learning a new language and toolchain, it's the right call.
How large is the final .dmg?
136 MB. That's almost entirely Electron. The actual Laravel app is small, around 500 lines of PHP across models, commands, and Livewire components.
What happens if a user disables all challenges?
The app falls back to the full built-in challenge list. You can disable individual challenges, but the overlay always has something to show. No empty state.
Conclusion
NativePHP is production-ready for focused desktop apps. You don't need Swift. You don't need Objective-C. If you know Laravel, you can ship something real.
The hard part isn't the UI. It's understanding the web context versus the child process context, and what that means for which APIs are available to you. Once that distinction is clear, everything else is just Laravel.
ForcedBreak is available to download on GitHub. If you're building something with NativePHP and hit a wall, get in touch.



Top comments (0)