Grove · · 7 min read

Grove 1.3.0: The Application Decides

Laravel 13.16 shipped php artisan dev, and Grove's hardcoded two-process dev session became a limitation. Grove 1.3.0 reads dev:list from your app instead of guessing — plus a pre-existing orphaned-process bug that testing dragged into the light.

Grove 1.3.0: The Application Decides

On the difference between knowing what to run and knowing how to run it — and why Grove stopped trying to do both.

There's a particular kind of bug that only shows up when you're being helpful.

Grove has always run your dev processes for you — a Vite server for HMR, a queue worker if you've got a real queue driver. Two processes, hardcoded, started when you type grove dev start. It worked, and it saved you an open terminal.

Then Laravel 13.16 shipped php artisan dev, and Grove's helpfulness quietly became a limitation.

Why this needed to change

Laravel's new dev command does something Grove couldn't: it lets your application declare what a dev session needs. The configuration moved out of composer.json and into your code, through a class called DevCommands:

use Illuminate\Foundation\Console\DevCommands;

DevCommands::artisan('reverb:start', 'reverb')->orange(); DevCommands::register('stripe listen --forward-to '.config('app.url'))->green();

Suddenly Grove was the least informed party in the room. Your app knew it needed Reverb. It knew about the Stripe webhook forwarder. Grove knew about npm run dev and queue:work, because that's what someone typed into a Rust file months ago.

It got worse in the details. Grove assumed npm. If your project used pnpm or bun, tough luck. And Grove ran queue:work, which doesn't pick up code changes without a restart — while Laravel's own default is queue:listen, which does. For a dev session, Laravel was simply right.

So 1.3.0 stops guessing. Grove now asks.

How it works

Laravel exposes the list as JSON. Here's a stock 13.16 app:

$ php artisan dev:list --json
[
{"command":"php artisan serve --host=localhost",             "name":"server", "priority":0},
{"command":"php artisan queue:listen --tries=1 --timeout=0", "name":"queue",  "priority":0},
{"command":"php artisan pail --timeout=0",                   "name":"logs",   "priority":0},
{"command":"npm run dev",                                    "name":"vite",   "priority":0}
]

Grove reads exactly this, then throws two entries away.

server goes, because Grove is the server. Your site is already being served over FastCGI at https://myapp.test with a trusted certificate. Starting artisan serve --host=localhost alongside it isn't just redundant — it's actively misleading, a second URL that behaves differently from the real one.

logs goes, because grove logs already tails your application log, and the GUI has a panel for it.

What's left gets supervised. So on the app above, you get:

$ grove dev start
✓ dev started for myapp: queue, vite

And with Reverb registered in a service provider:

$ grove dev start
✓ dev started for myapp: queue, vite, reverb

That third process is the whole point. Grove never knew about it before.

Your runtimes, not your PATH

Laravel hands over a full shell string — "php artisan queue:listen --tries=1 --timeout=0", "bun run dev". Grove doesn't just run it. It rewrites php to the CLI binary your site pins, and resolves Node package managers through its own bundled Node:

$ grove logs myapp-queue
started queue:listen via /Library/Application Support/Grove/runtimes/cli/8.5/php

This has a nice side effect. Because Laravel's DevCommands::node() uses its own lockfile detection, a bun project declares bun run dev — and Grove just runs it. The pnpm/yarn/bun gap closed itself, without a line of package-manager code in Grove.

Each process gets its own log, named after the process:

$ grove logs
service    grove · dev-myapp-queue.log
service    grove · dev-myapp-reverb.log
service    grove · dev-myapp-vite.log

Vendor packages don't get a vote

Grove passes --except-vendor. If a Composer package registers a dev process, Grove ignores it. A dependency you installed for something unrelated shouldn't be able to start a long-running process inside a background daemon that runs as root. Laravel's own defaults and your own code count; vendor/ doesn't.

When your app can't answer

Not every site is Laravel, and not every Laravel is 13.16. If dev:list doesn't exist or the app can't boot, Grove falls back to exactly what it did before — Vite plus a queue worker. Nothing regresses; you just don't get the new powers.

The bug we found by testing

Here's the part I didn't expect.

While verifying the new code, we started three processes and stopped them. Two died. One didn't:

$ ps -o pid,ppid,command -p 565
PID  PPID COMMAND
565     1 node -e ...        ← orphaned

PPID 1. Adopted by init.

The reason is mundane and had been sitting in Grove since the feature shipped: npm run dev doesn't become Vite, it spawns Vite. Kill the npm process and the grandchild survives — still holding port 5173. Which means your next grove dev start gets a Vite on 5174, your asset URLs point at the wrong port, and you spend twenty minutes doubting your vite.config.js.

This was never new. It was a pre-existing bug that the old two-process design just happened to hide well. Reading the app's declaration made it worse, because now there are more processes and more of them have children.

The fix: every dev process gets its own process group, and teardown signals the whole group — SIGTERM first so Vite and your queue worker can exit cleanly, SIGKILL for anything stubborn.

There's a guard in there worth mentioning, because it protects against something genuinely nasty. If setting up the process group ever failed, the child would inherit Grove's own group — and signalling that group would kill the daemon. So before signalling, Grove verifies the child really does lead its own group. We tested this under root, with privileges being dropped, and watched the daemon survive its own teardown.

Don't run both

grove dev and php artisan dev supervise the same processes. Running both gives you two Vite servers competing for one port and two queue workers competing for the same jobs — subtly, with no error message.

Grove now notices:

$ grove dev start
✓ dev started for myapp: queue, vite
! php artisan dev is already running — if it belongs to this site, stop it.
Grove supervises the same processes, so they are now running twice.

It's a warning, not an error, and deliberately so: the check can see that a php artisan dev is running, but not which project it belongs to. Better to tell you and let you judge.

Getting that detection right took two attempts, both instructive. The first version matched artisan dev anywhere in a command line — and immediately fired on our own test script, which merely contained the string. The second version anchored on token position — and broke on Grove's own PHP, because it lives under /Library/Application Support/…, and ps reports command lines unquoted, so the path shatters into three tokens. The version that shipped scans for the PHP binary itself, and knows that php8.5 is one but php.ini and phpstan aren't.

Small things that were annoying

grove dev start no longer needs a site name. It defaults to the site in your current directory, resolved from grove.toml's name or the directory name — the way grove link, grove secure and grove up always have:

~/Code/myapp $ grove dev start
✓ dev started for myapp: queue, vite

grove path install now installs grove itself. This one was embarrassing. You'd download the macOS app, run grove path install to get Grove's PHP and Node on your PATH, and still have no grove command — because the binary only ever existed inside the .app bundle. It now sits alongside the toolchain shims where you'd expect.

Upgrading

The macOS app auto-updates. Because the dev-process logic lives in the daemon rather than the CLI, you'll want to reinstall the service so the background daemon picks it up:

sudo grove install

Then delete the dev script from your composer.json if you like, and use grove dev instead.

One honest note: on Laravel 13.16+, grove dev start boots your application once to read dev:list. That's a beat of startup latency, and your service providers do run. If the app can't boot, Grove falls back rather than failing — but it's a real change in what happens when you press the button.

Two more things to know. queue:listen replaces queue:work for declared processes, which means your queue now picks up code changes without a restart — better, but different. And prebuilt macOS releases are Apple Silicon only; notarizing the Intel build on CI hangs more often than it succeeds, so Intel Macs should build from source with cargo build --release.

The shape of it

The nice thing about this release isn't the feature list. It's the division of labour it settles.

Laravel knows what your dev session needs. It has the service container, the config, the packages, and now a first-class way to declare all of it. Grove knows how to run things: without an open terminal, with per-process logs, with pinned runtimes, starting at boot, stopping cleanly.

Those were always two different jobs. For a while Grove tried to do both, and did the first one badly. Now it does one of them properly and asks about the other.