--- title: "Local Laravel with nix and devenv" description: "We have Herd at home." pubDate: 2026-05-19 heroImage: "/images/blog/nix-meme.jpg" tags: [laravel] --- I've been on a spiritual journey this past year or so really tinkering with my dev workflow and spending an alarming amount of time in dotfiles tweaking this or that. It started off with tmux a couple years ago and multiple failures to launch trying to get myself using neovim. Once I got to the point where I physically couldn't leave my terminal (cue the "stuck in vim" memes), I moved onto finding terminal replacements for tools I use and pay for (most of the time, happily). A few notable ones: - [Postman](https://www.postman.com/) => [hurl](https://hurl.dev/) - [JetBrains](https://www.jetbrains.com/) => [neovim (btw)](https://neovim.io/) - [TablePlus](https://tableplus.com/) => [lazysql](https://github.com/jorgerojas26/lazysql)/[harlequin](https://github.com/tconbeer/harlequin) I'm happy with my setup that I've fine tuned for Laravel and PHP, but I wanted to take it one step farther and see if I could home grow a Herd replacement to save yet another subscription. I should preface that **you should use, and happily pay for, Herd**. It's hands down the best Laravel/PHP get-your-project-off-the-ground tool out there right now (imo). You can fiddle with Docker containers, tweak volume paths, rage at how much storage Docker is filling up on your machine, etc. though Herd is a one-time install that removes all the noise so you can focus on building stuff. With that said, Herd can have some rough edges. One of the things I've really embraced since letting Claude ~~run my life~~ help manage my workflow is a worktree-first mindset allowing multiple agents to work on multiple things at once without stepping on each other's robot toes. I even [wrote](/blog/wonderful-world-of-worktrees-and-laravel) about getting Herd setup for worktrees with the help of [worktrunk](https://worktrunk.dev/), another tool I've been evangelizing to whoever will listen to me yap about it. While it's a fine solution (I think, at least), it can always be made better. For example, your worktree setup has to know about Herd linking, which configures nginx sites for you under the hood. That's global state as Herd has to run dnsmasq as root to allow the use of portless `.test` domains. That's all fine, but a bit against the ethos of isolated environments that worktrees aim to provide. Another thing is global state, particularly with PHP and the associated services. In my experience, Herd can often step on its own toes when spinning up services like FPM for specific versions of PHP. If you're working on multiple projects across multiple PHP versions, or maybe the same project, but in a branch/worktree that's upgrading a PHP version, you might run into port contention at some point and have to do a round of process massacre to get back to a stable state. This compounds when running multiple versions of PHP FPM as well, where the port contention can become painful sometimes with a handful of worktrees running concurrently. These are **not Herd's fault** at all. This is a result of trying to wrangle processes all in coordination on a machine, which has been a thorn in the side of developers for as long as professional programming has been a thing. There's alternatives, though. And one alternative I've been particularly keen on is [nix](https://nixos.org/) with [devenv](https://devenv.sh/). ## Going Herd-less One of the great things Herd does is manage services like Redis, your databases (MySQL, Postgres, etc.), Typesense, mail, and a whole litany of stuff. Pretty much anything you need to run an app, Herd has a managed process for it. One thing that I don't particularly _love_ about Herd, though, is how these services are somewhat obfuscated from you and abstracted away. And rightfully so. I mean, we're building apps and writing code (lmao, no we're not). I don't always want to deal with setting up databases and making sure Redis is running. That's where Herd is _excellent_ for the DX, managing the stuff I don't really care about. But sometimes, I do care about it. And the older I get, the more "control freak" I become where I want to know exactly what's running in my dev environment at all times and how I can manage it. That's where nix comes in. I won't go into much detail as I'm still a nix noob myself, but for what I'm using it for, I can't see a world where I go back. Nix provides a framework for declarative system builds for whatever you need. It's like a `composer.json` or `package.json` file but for your machine environment. You tell nix via devenv that for a particular project you need PHP, a specific version of it, what extensions you need, what database you need running, what version of Redis, etc. You probably get the picture, but the biggest win is that these are defined in a file that you can version control, tweak, manage, remove things, add services and tools, and anything else your project needs in its environment to run. This alone is the perfect marriage for worktrees, where a worktree exists in _complete_ isolation from other worktrees where I'm free to tinker with its environment if I need to _without_ affecting any other worktree. For example, if I'm doing a PHP 8.3 to PHP 8.4 upgrade, I keep `main` declaratively pinned to 8.3, while my `feature/update-php-to-8.4` worktree branch pins itself to 8.4 with maybe a few extra extensions and some `php.ini` tweaks and I can run both simultaneously in parallel without any headache. > You can absolutely still do this in Herd. There's a few gotchas, though it's still doable (read the other blog post!) [Devenv](https://devenv.sh/) is a layer that sits atop nix that makes it ridiculously simple to declare what your project and environment needs to hit the ground running. All managed in a single `devenv.nix` file. The real power here with devenv comes to light when taking a [bare repository layout](https://worktrunk.dev/tips-patterns/#bare-repository-layout) to your repository. What this means is instead of version controlled project existing in a single folder where its `.git/` directory lives _within_ the repository folder itself, you move `.git/` up a level as a sibling folder. This is perfect for worktree-driven development, as `.git/` is global repository state, and all linked worktrees are just folders within a parent project folder. In practice, this looks like: ```bash my-awesome-project/ .git/ main/ feature-foo-bar/ fix-foo-baz/ ``` Where `git switch` now becomes `cd` into whatever branch/worktree I need to be on. The git state is managed globally among the worktrees, and there's no need for a dedicated worktrees folder where you might store your worktrees. Rewiring the ole noggin a bit, this approach to a project's version control goes hand-in-hand with devenv, where each folder now gets its own runtime environment, from services, tools, secrets, etc. But we're not exactly Herd-less yet. While devenv manages the things we need to run our apps, we still need to see the dang thing run in a browser. That's where [Caddy](https://caddyserver.com/) comes in. ## Not your dad's web server Caddy is the spiritual successor to nginx. It's fast, has a bunch of modern features, an nginx-like DSL, written in Go, etc. I'm not a caddy expert, I just need local reverse proxying. That's what caddy helps accomplish akin to how Herd uses nginx to forward those `my-awesome-project.test` requests to the correct running instance of the app locally. For example, the caddy file for my website here looks like this: #### joeymckenzie.tech.test.caddy ```caddy joeymckenzie.tech.test { reverse_proxy 127.0.0.1:8100 } assets.joeymckenzie.tech.test { reverse_proxy 127.0.0.1:5273 } ``` I keep all my caddy sites in my `~/.config/caddy/sites` folder and wire them up in a `Caddyfile` that nix declaratively manages for me, like so: #### `~/.config/nix-darwin/darwin/services.nix` ```nix environment.etc."caddy/Caddyfile".text = '' { local_certs } import /Users/${username}/.config/caddy/sites/*.caddy ''; ``` I'm telling nix here that my system, when I build it, needs a `Caddyfile` running within `/etc` that nix generates when I build my system. I'm glossing over a ton of details here, namely [nix-darwin](https://github.com/nix-darwin/nix-darwin) and [home-manager](https://github.com/nix-community/home-manager) for declaratively managing my MacBook Pro, but I promise I'll write about those soon. In the meantime, you can poke around my [nixfiles](https://github.com/JoeyMckenzie/nixfiles) if you want to see the full setup. Skimming over the finer details, what this looks like in my system declaration where I tell nix what services I want launched as background processes when I build is: ```nix launchd.daemons.caddy = { serviceConfig = { ProgramArguments = [ "${pkgs.caddy}/bin/caddy" "run" "--config" "/etc/caddy/Caddyfile" "--adapter" "caddyfile" ]; RunAtLoad = true; KeepAlive = true; EnvironmentVariables = { HOME = "/var/root"; XDG_CONFIG_HOME = "/var/root/.config"; XDG_DATA_HOME = "/var/root/.local/share"; }; StandardOutPath = "/var/log/caddy.out.log"; StandardErrorPath = "/var/log/caddy.err.log"; }; }; ``` Now when I rebuild my system via `nix-darwin`, caddy launches in the background and I have full access to the `caddy` CLI to do whatever I need. More importantly, because I'm launching caddy as a background process from the root, I get access to ports :80 and :443, so I can run my Laravel apps from a TLD without specifying a port that it needs to run on. This is essentially what Herd does, but with nginx instead. ![Drew Carey meme](/images/blog/drew-carey-meme.png) Now the other side of the coin: [dnsmasq](https://thekelleys.org.uk/dnsmasq/doc.html). I wire caddy up so that it knows that any caddy file setup in `~/.config/caddy/sites` needs reverse proxying. I'll get into _how_ exactly I do that in a bit, but the important information is that caddy knows that there's sites it needs forward requests to that run on my local machine. _Something_ has to tell the browser though that those requests actually need to go to `127.0.0.1`, the artist also known as `localhost`. With dnsmasq, requests from the browser to a TLD like `.test` will ask `/etc/resolver/test` _where_ those requests need to go. Dnsmasq answers that question with "to `127.0.0.1`, my guy," and all is good in the world. With dnsmasq directing browser requests and caddy acting as a reverse proxy, `.test` domains work exactly as they do with Herd. I setup dnsmasq as another launch service so that when I (re)build my system with nix, it starts up alongside caddy and I'm off to the races: ```nix services.dnsmasq = { enable = true; addresses = { test = "127.0.0.1"; }; }; environment.etc."resolver/test".text = '' nameserver 127.0.0.1 ''; ``` ## Declarative nix files Okay, that's enough networking for the week. Now to the fun stuff, actually running a dev environment for Laravel. For a full-fledged Laravel app, we need a few things going for us: - We need PHP and JavaScript (obviously) - We need environment variables - We need queues, so Redis and maybe Horizon - We need a database: MySQL, Postgres, SQLite, etc. - We might want full-text search with meilisearch or typesense - We might need feature flags with LaunchDarkly - ...and any other service that our app needs to run And that's where nix joins us once again, to set those things up for us in an isolated development shell, hidden from the outside world. The devenv [docs](https://devenv.sh/getting-started/) will do a better job of getting you setup with nix and devenv than I ever will, so I highly encourage you to give them a quick once over. Within a project, you can run `devenv init` to get a pretty bare bones setup for a `devenv.nix` file: ```nix { pkgs, lib, config, inputs, ... }: { # https://devenv.sh/basics/ env.GREET = "devenv"; # https://devenv.sh/packages/ packages = [ pkgs.git ]; # https://devenv.sh/languages/ # languages.rust.enable = true; # https://devenv.sh/processes/ # processes.dev.exec = "${lib.getExe pkgs.watchexec} -n -- ls -la"; # https://devenv.sh/services/ # services.postgres.enable = true; # https://devenv.sh/scripts/ scripts.hello.exec = '' echo hello from $GREET ''; # https://devenv.sh/basics/ enterShell = '' hello # Run scripts directly git --version # Use packages ''; # https://devenv.sh/tasks/ # tasks = { # "myproj:setup".exec = "mytool build"; # "devenv:enterShell".after = [ "myproj:setup" ]; # }; # https://devenv.sh/tests/ enterTest = '' echo "Running tests" git --version | grep --color=auto "${pkgs.git.version}" ''; # https://devenv.sh/git-hooks/ # git-hooks.hooks.shellcheck.enable = true; # See full reference at https://devenv.sh/reference/options/ } ``` This is a basic devenv nix file. The nix DSL is pretty human friendly, but there's a whole language manual worth a lazy Sunday afternoon read. A nix file is basically a recipe to tell nix how to construct the development shell you'll be working in, declaring what languages, tools, and services you might need to get the project running. That `{ pkgs, lib, config, inputs, ... }:` block at the top of the file is an entry point for nix, of sorts, that kind of acts like a function signature. This is saying "I need the nix built-in modules for `pkgs`, `lib`, `config`, and `inputs`", where our recipe uses these to hook in the things we need. The rest of the scope block, or the stuff in between the brackets, is just a recipe for how to build this dev shell. We assign an env var for the environment, wire up git as a tool for us to be able to use, build out some arbitrary scripts to execute, and some hooks for when we enter the dev shell that'll run some arbitrary commands as well as a hook for when we want to specifically run tests on this shell ## Laravel-ization Now we need to Laravel-ize it. I'm going to be using [my fork](https://github.com/JoeyMckenzie/laravel-official-react-starter-kit/tree/feature/add-nix-support) of the official Laravel/React starter kit to build up a `devenv.nix` file that allows us to run a Laravel app, so feel free to reference that file anytime. I have a separate branch in `feature/add-nix-support` that I usually clone anytime I start a new project that inevitably doesn't go anywhere. First, we get rid of everything so we can work from a clean slate. For my setup, I only use `pkgs` and `lib` as the named arguments as I don't need `config` or `inputs`: ```nix { pkgs, lib, ... }: ``` Next, we need some variables to pass around for the app setup for the common things we'll be reusing: ```nix { pkgs, lib, ... }: let # ─── Project identity ───────────────────────────────────────────────── # Kebab for hosts/Caddy site files, snake for Postgres identifiers # (which can't contain dashes without double-quoting at every callsite). appName = "laravel-official-react-starter-kit"; appSlug = lib.replaceStrings [ "-" ] [ "_" ] appName; # ─── Worktree identity ──────────────────────────────────────────────── worktreeName = let base = builtins.baseNameOf (toString ./.); in if base == appName then "main" else lib.toLower base; isPrimary = worktreeName == "main"; # ─── Per-worktree slot allocation ───────────────────────────────────── # # Problem: running multiple git worktrees of this project at the same # time (e.g. `main` plus a feature branch) would have them fight over # the same TCP ports (php-fpm, vite), the same Postgres database name, # the same Redis DB number, and the same `.test` hostname. The second # `devenv up` either crashes on port-bind or silently corrupts the # first worktree's state. # # Solution: each worktree gets a stable integer slot (0-499) derived # from a hash of its directory name. That slot becomes a port offset # (phpPort = 8000 + slot, vitePort = 5173 + slot), a Redis DB index, # a database-name suffix, and a hostname prefix. The primary checkout # is always slot 0 / standard ports / unsuffixed names so it gets the # most ergonomic URLs. # # The slot is derived from a hash of the directory name (rather than # from a shared registry or by enumerating worktrees at eval time) so # it's stable across re-clones and needs no coordination between # worktrees. mod 500 keeps the birthday-collision probability under # ~9% across 10 active worktrees; if you draw the short straw, drop # any unused integer 1-499 into `.devenv-index` to pin the slot by # hand. # # Redis is the one special case: the daemon only ships with 64 DBs, # so the Redis slot mods by 63 instead. A collision there is harmless # because HORIZON_PREFIX further scopes the keyspace per worktree, so # two worktrees sharing a Redis DB index can coexist without keys # bleeding across. hexDigit = c: { "0" = 0; "1" = 1; "2" = 2; "3" = 3; "4" = 4; "5" = 5; "6" = 6; "7" = 7; "8" = 8; "9" = 9; "a" = 10; "b" = 11; "c" = 12; "d" = 13; "e" = 14; "f" = 15; } .${c}; hashValue = let hex = builtins.substring 0 4 (builtins.hashString "sha256" worktreeName); chars = lib.stringToCharacters hex; in lib.foldl' (acc: c: acc * 16 + (hexDigit c)) 0 chars; hashIndex = (lib.mod hashValue 500) + 1; indexFile = ./.devenv-index; index = if isPrimary then 0 else if builtins.pathExists indexFile then lib.toInt (lib.removeSuffix "\n" (builtins.readFile indexFile)) else hashIndex; redisDbIndex = if isPrimary then 0 else (lib.mod hashValue 63) + 1; # Per-worktree ports. Postgres/Redis/Caddy/Mailpit are nix-darwin daemons # on their standard ports, see ~/.config/nix-darwin/darwin/services.nix. phpPort = 8000 + index; vitePort = 5173 + index; # ─── Names + hosts ──────────────────────────────────────────────────── dbName = if isPrimary then appSlug else "${appSlug}_" + lib.replaceStrings [ "-" "." "/" ] [ "_" "_" "_" ] worktreeName; appHost = if isPrimary then "${appName}.test" else "${worktreeName}.${appName}.test"; # vite.config.ts should set `server.origin` to this and `server.hmr.host` # to this with `protocol = 'wss'` + `clientPort = 443` so HMR works over # HTTPS without mixed-content blocks. viteHost = "vite.${appHost}"; home = builtins.getEnv "HOME"; caddySite = "${home}/.config/caddy/sites/${appName}-${worktreeName}.caddy"; caddySitesDir = "${home}/.config/caddy/sites"; worktreesRoot = builtins.dirOf (toString ./.); in { # TODO } ``` Okay, that's a lot of shit going on in the setup. The `let` binding functions pretty much like any other programming language that supports block-level assignments, just declaring a bunch of variables in a block to reuse. I use a few things here: - We start off with project identities so we can reuse the project name for hosts, DBs, worktrees, etc. - Slugifying the project name allows for a lot of reuse in our services, as we'll see in just a bit - Next, we have to do a bit of port olympics to keep ports from colliding as spin up worktrees - If you don't use worktrees, you can remove that whole section - If you _do_ use worktrees (which I'd highly recommend), the hashing function there allows for ~500 free ports - I have a poor man's port allocator workaround where I use a `.devenv-index` file with a static number in to override the port index if I need to (rarely) - This is totally optional, but allows for a truly per-worktree isolated environment - We do a bit of determination to figure out the appropriate host name based on the worktree - Same thing for our vite server, since we have configured with caddy to service at a `vite.` apex domain - I make a few references to the caddy site and the worktrees root that'll be used here in just a bit ## Declaring the important stuff Okay, with that out of the way, let's get into the meat and potatoes of my `devenv.nix` recipe. First thing's first, We need some AI integration because I have no idea how to write code by hand anymore: ```nix { pkgs, lib, ... }: let # ...other stuff in { dotenv.disableHint = true; claude = { code.enable = true; code.mcpServers = { devenv = { type = "stdio"; command = "devenv"; args = [ "mcp" ]; }; shadcn = { type = "stdio"; command = "npx"; args = [ "shadcn@latest" "mcp" ]; }; boost = { type = "stdio"; command = "php"; args = [ "artisan" "boost:mcp" ]; }; }; }; } ``` The `in` block captures over variables in the `let` block where I'm now telling nix that I need a few things in my dev environment. We'll flesh this out bit-by-bit, but to start, I like to wire up Claude Code integration as it's my agentic development daily driver and through nix, I can consolidate all of my Claude Code config into a nix file alongside all the other stuff I need to run my code. Nix becomes the source of truth for _everything_ taking this approach. Whether that's good or bad, I'll let you decide. I've spent enough time working at places where dev environment setup took two weeks, a lot of patience, and even more cursing waiting on ServiceNow tickets to be resolved to get access to things that I'd never touch again. I want to clone a project and hit the ground running within minutes, and that's what I tailor my approach here for. Okay, rant aside, next we need to tell nix what languages and language tooling we're going to need to get us off the ground: ```nix let # ...other stuff in { # ...claude stuff # ─── Languages ───────────────────────────────────────────────────────── languages = { php = { enable = true; version = "8.4"; extensions = [ "bcmath" "gd" "zip" "pdo_pgsql" "redis" "opcache" "intl" "xdebug" ]; ini = '' ${builtins.readFile ./php.ini.base} ${lib.optionalString (builtins.pathExists ./php.local.ini) (builtins.readFile ./php.local.ini)} ''; fpm.pools.web = { settings = { "listen" = "127.0.0.1:${toString phpPort}"; "pm" = "dynamic"; "pm.max_children" = 10; "pm.start_servers" = 2; "pm.min_spare_servers" = 1; "pm.max_spare_servers" = 3; # Inherit the devenv shell env so DB_DATABASE / REDIS_DB / # SESSION_DOMAIN / HORIZON_PREFIX reach the FPM workers. Without # this, php-fpm scrubs the env and only the .env file is visible. "clear_env" = "no"; # macOS fork-safety workaround for pdo_pgsql. libpq tries GSSAPI # encryption negotiation by default, which pulls Kerberos → # CoreFoundation prefs → mach IPC. The IPC state can't be reused # after fork(), so php-fpm workers SIGSEGV inside PQconnectdb. # Setting PGGSSENCMODE=disable tells libpq to skip the GSSAPI # codepath entirely, so no kerberos init, no CFPrefs, no crash. "env[PGGSSENCMODE]" = "disable"; }; }; }; javascript = { enable = true; package = pkgs.nodejs_22; npm.enable = true; }; }; } ``` Alright, now we're getting somewhere. We know we're going to need PHP and JS, along with a few PHP extensions to get going and some FPM configuration. The JS block is pretty simple, just telling nix I need node on v22. Notice there's no mention of nvm, asdf, mise, etc. The beauty of per-project devenv nix environments is that we don't _need_ version managers, because our environment is pinned to the exact version we need. Working on multiple projects at a time? Update your `devenv.nix` accordingly and you're off to the races. For our PHP setup, we include the usual suspects we need for a solid PHP extension setup with xdebug, zip, opcache, etc. I'm opting to use Postgres like the rest of the modern world, and things get a bit interesting here. I'm gonna pause here, as I think it'd be valuable for anyone to read two posts that are pretty important for our setup here: - [Fixing Postgres Connection Issues in Laravel Valet on macOS](https://janostlund.com/2024-08-16/502-error-laravel-valet-pgsql) - [Laravel valet 502 error when using postgres pgsql driver](https://www.sabatino.dev/laravel-valet-502-error-when-using-postgres-pgsql-driver/) Yeah, we're not using Valet, but this bad boy bit me while working on a recent project and had me scratching my head for longer than I'd care to admit. If you didn't read those, here's the TL;DR: - When you use `php artisan serve`, your Laravel app talks directly to Postgres with no frills or fuss - When you reverse proxy to your Laravel app through something like caddy, you usually have PHP FPM handling those requests - PHP FPM manages a pool of PHP workers ready to handle requests, but lives outside of Laravel's PHP view of the world - _Those_ PHP request workers use separate configuration when connecting to Postgres - Postgres relies on something called Generic Security Services API, or GSSAPI - When a worker attempts to talk to Postgres, Postgres uses GSSAPI to authenticate the worker - When you're on a local dev machine, odds are you don't need this level of authentication (unless you're working in an enterprise environment with Kerberos/AD everywhere) - To get around this authentication blocking Postgres will do to a PHP FPM worker, you set `env[PGGSSENCMODE]` to `disable` - Congrats, your PHP FPM workers no longer 502 when handling requests sent from caddy The TL;DR of the TL;DR above - when using a reverse proxy on a Laravel app with Postgres, you set `env[PGGSSENCMODE] = disable` in your FPM config. Without it, you'll get some mysterious 502 errors back from FPM attempting to handle the request. I'm glad I know this now, but boy... that sure was annoying trying to figure out in the first place. With that out of the way, let's move onto to what we _actually_ need to run when we're spin up our app. In this contrived example, I have a few things setup: - We have our Laravel app that needs to handle requests - We have an asset server for vite - We need a queue worker via Horizon (because I couldn't imagine a real Laravel app without it) - We need a scheduler running to test those pesky scheduled commands We can define _all_ of that in our devenv nix setup. All of these processes and services, though, need environment variables at some point to know _how_ they should run. So, we wire it all up with an `env` and `processes` block in our nix setup: ```nix let # ...other stuff in { # ─── Per-worktree env ───────────────────────────────────────────────── # Only worktree-dependent values live here. Static project config (DB # driver/host/creds, Redis host, mail host, etc.) lives in .env so that # artisan commands work the same whether or not the devenv shell is # active. Anything in this block overrides .env at runtime via # phpdotenv's immutable mode. env = { APP_URL = "https://${appHost}"; APP_DOMAIN = appHost; ASSET_URL = "https://${appHost}"; # Vite served behind caddy. VITE_DEV_SERVER_URL is what laravel-vite- # plugin emits in