What were you trying to do?
Ship a Laravel 13 + Filament 5 desktop app to users on low-end Windows machines, and account for everything the app does while idle.
The app schedules nothing — routes/console.php is untouched, there is no withSchedule() anywhere — but it still pays for the scheduler on every machine, for the whole session.
What happened?
NativePHP.bootstrap() calls startScheduler() unconditionally, which spawns a fresh php artisan schedule:run process every 60 seconds for the lifetime of the app:
Each tick is a new process, so an in-memory opcache never applies to it. It is a full Laravel boot, performed and discarded, 1440 times a day.
Measured on an Apple M3 Pro (macOS 26.6), with config:cache, route:cache and event:cache already applied — a machine far faster than the ones we ship to:
|
|
| PHP files included per bootstrapped console kernel |
1410 |
Wall time per artisan schedule:run |
~240 ms (~190 ms user CPU) |
| Times per day |
1440 |
| Scheduled tasks in the app |
0 |
(That file count is for our app — Laravel 13 + Filament 5. A vanilla app would be lower, but it is still a complete framework boot either way.)
On Windows this is worse in kind rather than just degree: every one of those file reads goes through Defender's real-time scanner, which is the mechanism identified as the root cause in #12. That issue is about the cost being paid on first load; this is the same cost paid again every single minute, in the background, on a machine the user believes is idle.
There is no way to turn it off. I grepped the whole package — no config key, no env var, no hook. The only route today is patching the vendored TypeScript, which is what we now do.
How to reproduce the bug
- Create a NativePHP desktop app with no scheduled tasks at all (a fresh install qualifies).
php artisan native:run, or launch a built app.
- The console logs
Running scheduler... every 60 seconds.
- A new
php process appears in the process list on each of those ticks.
There is nothing to configure to stop it.
Debug Output
{
"Environment": {
"PHP": { "Version": "8.4.24" },
"Laravel": { "Version": "13.29.0", "ConfigCached": false, "RoutesCached": false, "DebugEnabled": true },
"Node": { "Version": "v20.19.0" },
"NPM": { "Version": "10.8.2" },
"OperatingSystem": "Darwin",
"ElectronRoot": "vendor/nativephp/desktop/resources/electron"
},
"NativePHP": {
"Versions": { "nativephp/desktop": "2.2.1.0", "nativephp/php-bin": "1.2.0.0" },
"Configuration": {
"Provider": "App\\Providers\\NativeAppServiceProvider",
"BuildHooks": { "Pre": [], "Post": [] },
"NotarizationEnabled": false,
"AzureTrustedSigningEnabled": true,
"CustomPHPBinary": false
}
}
}
Confirmed still present on main at 8ba6171 — the file has only had formatting changes since #69.
Which operating systems have you seen this occur on?
macOS, Windows
Notes
Two ways to fix it, and either works for us:
- A config toggle. Something like
'scheduler' => ['enabled' => env('NATIVEPHP_SCHEDULER_ENABLED', true)] in config/nativephp.php, checked before startScheduler(). Explicit, and nothing changes for anyone who does not set it.
- Detect it at boot. Run
artisan schedule:list once at startup and skip the interval when it comes back empty. No config at all, but it would surprise an app that registers schedules conditionally at runtime — so (1) looks safer to me.
Happy to open a PR for whichever you would prefer.
What were you trying to do?
Ship a Laravel 13 + Filament 5 desktop app to users on low-end Windows machines, and account for everything the app does while idle.
The app schedules nothing —
routes/console.phpis untouched, there is nowithSchedule()anywhere — but it still pays for the scheduler on every machine, for the whole session.What happened?
NativePHP.bootstrap()callsstartScheduler()unconditionally, which spawns a freshphp artisan schedule:runprocess every 60 seconds for the lifetime of the app:electron-plugin/src/index.ts#L126— called during bootstrap, no conditionelectron-plugin/src/index.ts#L276-L288—setInterval(..., 60 * 1000)electron-plugin/src/server/php.ts#L286—callPhp(['artisan', 'schedule:run'], ...)Each tick is a new process, so an in-memory opcache never applies to it. It is a full Laravel boot, performed and discarded, 1440 times a day.
Measured on an Apple M3 Pro (macOS 26.6), with
config:cache,route:cacheandevent:cachealready applied — a machine far faster than the ones we ship to:artisan schedule:run(That file count is for our app — Laravel 13 + Filament 5. A vanilla app would be lower, but it is still a complete framework boot either way.)
On Windows this is worse in kind rather than just degree: every one of those file reads goes through Defender's real-time scanner, which is the mechanism identified as the root cause in #12. That issue is about the cost being paid on first load; this is the same cost paid again every single minute, in the background, on a machine the user believes is idle.
There is no way to turn it off. I grepped the whole package — no config key, no env var, no hook. The only route today is patching the vendored TypeScript, which is what we now do.
How to reproduce the bug
php artisan native:run, or launch a built app.Running scheduler...every 60 seconds.phpprocess appears in the process list on each of those ticks.There is nothing to configure to stop it.
Debug Output
{ "Environment": { "PHP": { "Version": "8.4.24" }, "Laravel": { "Version": "13.29.0", "ConfigCached": false, "RoutesCached": false, "DebugEnabled": true }, "Node": { "Version": "v20.19.0" }, "NPM": { "Version": "10.8.2" }, "OperatingSystem": "Darwin", "ElectronRoot": "vendor/nativephp/desktop/resources/electron" }, "NativePHP": { "Versions": { "nativephp/desktop": "2.2.1.0", "nativephp/php-bin": "1.2.0.0" }, "Configuration": { "Provider": "App\\Providers\\NativeAppServiceProvider", "BuildHooks": { "Pre": [], "Post": [] }, "NotarizationEnabled": false, "AzureTrustedSigningEnabled": true, "CustomPHPBinary": false } } }Confirmed still present on
mainat 8ba6171 — the file has only had formatting changes since #69.Which operating systems have you seen this occur on?
macOS, Windows
Notes
Two ways to fix it, and either works for us:
'scheduler' => ['enabled' => env('NATIVEPHP_SCHEDULER_ENABLED', true)]inconfig/nativephp.php, checked beforestartScheduler(). Explicit, and nothing changes for anyone who does not set it.artisan schedule:listonce at startup and skip the interval when it comes back empty. No config at all, but it would surprise an app that registers schedules conditionally at runtime — so (1) looks safer to me.Happy to open a PR for whichever you would prefer.