I wanted a local dev manager Windows could run that starts and stops PHP, Nginx, and MySQL, switches between PHP versions, and wires up project.test virtual hosts, exactly like Laragon but built myself so I controlled every moving part. The shape of the answer is a Tauri v2 app with a Rust backend: spawn each service as a child process with std::process::Command, hold the live Child handles in Tauri managed state, and expose start/stop/status to the UI through commands. The genuinely hard part is not the buttons in the frontend, it is process supervision from Rust, plus two Windows-specific traps: editing the hosts file needs admin elevation, and child processes must be killed cleanly on exit or you leak mysqld instances that hold the data directory lock.
I picked Tauri over Electron for the usual reasons: a single-digit-megabyte installer instead of a hundred-plus, and a real systems language behind the window. If you want the longer argument, I wrote it up in why I reach for Tauri instead of Electron. This post is about the backend that actually manages the stack.
How do you spawn and supervise services from Rust?
Each service is a separate executable on disk: php-cgi.exe running as a FastCGI listener, nginx.exe, and mysqld.exe. I launch them with std::process::Command, keep the returned Child in a HashMap behind a Mutex, and register that map as Tauri managed state. The Child handle is what the rest of the design hangs on: it is how I later kill the process and how I check whether it is still alive. One caveat with Nginx on Windows: it runs a master plus one or more worker processes, and the Child you hold is the master you launched. Killing it terminates the master, but use nginx -s stop for a graceful shutdown that reaps the workers too.
use std::collections::HashMap;
use std::process::{Child, Command};
use std::sync::Mutex;
use tauri::State;
#[derive(Default)]
pub struct ServiceManager {
// service name -> live child handle
pub children: Mutex<HashMap<String, Child>>,
}
#[tauri::command]
pub fn start_service(
name: String,
exe: String,
args: Vec<String>,
mgr: State<'_, ServiceManager>,
) -> Result<u32, String> {
let mut map = mgr.children.lock().map_err(|e| e.to_string())?;
if map.contains_key(&name) {
return Err(format!("{name} is already running"));
}
let child = Command::new(&exe)
.args(&args)
.spawn()
.map_err(|e| format!("failed to start {name}: {e}"))?;
let pid = child.id();
map.insert(name, child);
Ok(pid)
}
#[tauri::command]
pub fn service_status(
name: String,
mgr: State<'_, ServiceManager>,
) -> Result<String, String> {
let mut map = mgr.children.lock().map_err(|e| e.to_string())?;
match map.get_mut(&name) {
// try_wait returns Ok(Some(_)) if the process has already exited
Some(child) => match child.try_wait().map_err(|e| e.to_string())? {
Some(status) => Ok(format!("stopped (exit {status})")),
None => Ok("running".into()),
},
None => Ok("stopped".into()),
}
}
#[tauri::command]
pub fn stop_service(
name: String,
mgr: State<'_, ServiceManager>,
) -> Result<(), String> {
let mut map = mgr.children.lock().map_err(|e| e.to_string())?;
if let Some(mut child) = map.remove(&name) {
child.kill().map_err(|e| e.to_string())?;
let _ = child.wait();
}
Ok(())
}The detail that matters is child.try_wait(): it is non-blocking and tells you whether a process died on its own, from a crash, a port conflict, or a bad config. Without it your UI shows a green dot for a mysqld that exited three seconds after boot. Register the state in the builder and hand the commands to the invoke handler.
use tauri::Manager;
fn main() {
tauri::Builder::default()
.manage(services::ServiceManager::default())
.invoke_handler(tauri::generate_handler![
services::start_service,
services::stop_service,
services::service_status,
])
.on_window_event(|window, event| {
// kill every tracked child before the window closes
if let tauri::WindowEvent::CloseRequested { .. } = event {
let mgr = window.state::<services::ServiceManager>();
if let Ok(mut map) = mgr.children.lock() {
for (_, mut child) in map.drain() {
let _ = child.kill();
let _ = child.wait();
}
}
}
})
.run(tauri::generate_context!())
.expect("error while running application");
}How do you generate Nginx virtual hosts so project.test resolves?
A virtual host is two pieces working together. First, an Nginx server block that maps the hostname to the project root and forwards .php requests to the FastCGI listener php-cgi is running. Second, a line in the Windows hosts file so the operating system resolves project.test to 127.0.0.1. I generate one .conf file per project into nginx/conf/sites/ and include them all from the main config. If you want the deeper reasoning on the proxy and FastCGI directives, I covered it in Nginx reverse proxy configuration explained.
server {
listen 80;
server_name myapp.test;
root C:/dev/myapp/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
# php-cgi.exe -b 127.0.0.1:9000 is the active PHP version
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}The hosts file is the part that bites people. It lives at C:\Windows\System32\drivers\etc\hosts and is not writable by a normal user, so a plain std::fs::write fails with access denied. You have to elevate. The clean approach on Windows is to shell out through the ShellExecute runas verb so the OS shows the UAC prompt, rather than trying to detect or fake admin rights inside the app. I write the new hosts content to a temp file and have an elevated helper copy it into place.
- Back up the original hosts file once before the first write, so a botched entry is recoverable.
- Wrap managed lines in a sentinel comment block (for example, # >>> devmanager) so you only ever rewrite your own section and never touch system entries.
- Trigger one UAC prompt per change set, not per project, by batching all pending hosts edits.
- Flush the DNS cache with ipconfig /flushdns after editing, or the old resolution lingers.
The frontend was a weekend. Reliable process supervision and surviving the UAC boundary on the hosts file took the rest of the month.
How do you switch PHP versions?
I keep each PHP build in its own folder, for example php/8.1, php/8.3, and php/8.4, each with its own php-cgi.exe and php.ini. Switching versions is nothing more than choosing which php-cgi.exe to launch as the FastCGI listener on 127.0.0.1:9000. The Nginx config never changes because it only ever talks to that fixed port. So switching is: stop the current php-cgi child, then start_service with the exe path pointing at the version the user selected. Because Nginx points at the port and not at a binary, no Nginx reload is required for a version swap.
use std::path::PathBuf;
use tauri::State;
use crate::services::{self, ServiceManager};
#[tauri::command]
pub fn switch_php(
version: String, // e.g. "8.3"
php_root: String, // e.g. "C:\\dev\\bin\\php"
mgr: State<'_, ServiceManager>,
) -> Result<u32, String> {
// stop the listener currently bound to :9000
services::stop_service("php-cgi".into(), mgr.clone())?;
let mut exe = PathBuf::from(&php_root);
exe.push(&version);
exe.push("php-cgi.exe");
if !exe.exists() {
return Err(format!("php-cgi.exe not found for {version}"));
}
// to_string_lossy keeps native Windows separators intact
services::start_service(
"php-cgi".into(),
exe.to_string_lossy().into_owned(),
vec!["-b".into(), "127.0.0.1:9000".into()],
mgr,
)
}Windows path handling, the quiet source of bugs
Build paths with std::path::PathBuf and .push() rather than concatenating strings, and let to_string_lossy() hand the final value to Command. Hardcoding backslashes works until a path contains a space, and forward-slash paths confuse some bundled binaries. One real gotcha: Nginx wants forward slashes in its root directive even on Windows, while php-cgi and the OS are happy with either, so I normalize to forward slashes only when writing Nginx config and leave native separators everywhere else.
Why does killing children on exit matter so much?
On Windows a child process does not die just because its parent does. Close the window without the CloseRequested handler above and you orphan mysqld, which keeps an exclusive lock on the data directory. The next launch then fails to start MySQL with a lock error that looks unrelated, and you have no idea a ghost process is the cause. The drain-and-kill loop in main.rs is not optional polish, it is what makes the tool trustworthy across restarts. One thing to know: on Windows, Child::kill calls TerminateProcess and ends only the process you spawned, not any grandchildren it forked, so kill the actual service binaries you launched and lean on each service's own graceful-stop command where one exists. For services you also expose from a tray icon, wire the same cleanup into the tray quit handler, which I detailed in Tauri system tray integration.
That is the whole spine of a working local dev manager on Windows: spawn services and hold their Child handles in managed state, poll status with try_wait so the UI never lies, generate Nginx server blocks plus elevated hosts-file entries for project.test, and switch PHP by repointing a single FastCGI port. Get the cleanup-on-exit path right first, because every other feature sits on top of processes you can actually trust to start and stop. Build the supervisor, then the buttons. The buttons are easy.

