<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Baptiste Bouillot</title>
    <description>The latest articles on DEV Community by Baptiste Bouillot (@croustibat44).</description>
    <link>https://dev.to/croustibat44</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3543764%2F53bbc965-0a43-4e9b-a094-8d35eeb6da6e.png</url>
      <title>DEV Community: Baptiste Bouillot</title>
      <link>https://dev.to/croustibat44</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/croustibat44"/>
    <language>en</language>
    <item>
      <title>Murmure 1.2.0: a signed DMG, no ffmpeg, and dictation that doesn't lose its first word</title>
      <dc:creator>Baptiste Bouillot</dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:50:04 +0000</pubDate>
      <link>https://dev.to/croustibat44/murmure-120-a-signed-dmg-no-ffmpeg-and-dictation-that-doesnt-lose-its-first-word-4jhp</link>
      <guid>https://dev.to/croustibat44/murmure-120-a-signed-dmg-no-ffmpeg-and-dictation-that-doesnt-lose-its-first-word-4jhp</guid>
      <description>&lt;p&gt;Murmure is a small menu bar app I built for voice dictation on macOS. You press Cmd+Shift+E, you talk, and the text is pasted into whatever app you're in. Transcription runs locally with &lt;a href="https://github.com/ggerganov/whisper.cpp" rel="noopener noreferrer"&gt;whisper.cpp&lt;/a&gt; and the &lt;code&gt;large-v3-turbo&lt;/code&gt; model: no account, no subscription, no API key.&lt;/p&gt;

&lt;p&gt;I &lt;a href="https://dev.to/croustibat44/murmure-system-wide-voice-dictation-for-macos-that-never-leaves-your-mac-2ale"&gt;wrote about the first public version&lt;/a&gt; a week ago. Version 1.2.0 is out today, and it fixes the thing I was least happy with: how you install it.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/croustibat/murmure" rel="noopener noreferrer"&gt;github.com/croustibat/murmure&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's new in 1.2.0
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A signed, notarized DMG.&lt;/strong&gt; Version 1.1.0 meant cloning the repo, having Homebrew and the Xcode command line tools, and running &lt;code&gt;./install.sh&lt;/code&gt;. That's fine for me and for a few developers, and a dead end for everyone else. Now you open &lt;code&gt;Murmure.dmg&lt;/code&gt;, drag the app to Applications, and it opens without a Gatekeeper warning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything is inside the app.&lt;/strong&gt; whisper.cpp is compiled for Apple Silicon and bundled. ffmpeg is gone, replaced by a native recorder. Nothing to install next to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automatic updates.&lt;/strong&gt; Murmure checks once a day and installs a new version when you say so. It never interrupts a dictation: if an update shows up while you're talking, it waits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model downloads from the app.&lt;/strong&gt; On first launch Murmure offers to download its transcription model (about 550 MB, once). The download resumes after a network cut and the file is checked against its SHA-256 hash before use.&lt;/p&gt;

&lt;p&gt;There are smaller fixes too. Unplugging a headset during a dictation no longer stops it: recording continues on another microphone. Long dictations keep the custom vocabulary better. And a Whisper noise label like &lt;code&gt;*sad*&lt;/code&gt; can no longer replace what you actually said.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why: the install was the product
&lt;/h2&gt;

&lt;p&gt;With 1.1.0, before your first dictation you needed Homebrew, the Xcode command line tools and a terminal. For a dictation app, that's backwards: a tool that saves you typing shouldn't start with a terminal session.&lt;/p&gt;

&lt;p&gt;Signing also matters for a less visible reason. macOS ties the Microphone and Accessibility permissions to the app's signature. With the old install, a copy built on your Mac and signed ad hoc was identified by its binary hash, so a rebuild could mean granting Accessibility again. With a Developer ID signature, permissions survive updates. That's what makes automatic updates bearable: an update that silently breaks auto-paste would be worse than no update.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha: the first word of every sentence
&lt;/h2&gt;

&lt;p&gt;Replacing ffmpeg with a native Core Audio recorder (&lt;code&gt;murmure-rec&lt;/code&gt;) was mostly about having one less dependency. It also fixed a bug.&lt;/p&gt;

&lt;p&gt;With 1.1.0, the start signal came as soon as the recording process was launched. But a microphone takes a moment to actually deliver audio. If you started talking right away, which is exactly what a shortcut invites you to do, the first syllables were lost.&lt;/p&gt;

&lt;p&gt;The new recorder only gives the start signal once the microphone is ready. It sounds obvious written down. It took a dedicated recorder to make it true.&lt;/p&gt;

&lt;p&gt;The same recorder lists your input devices, which helps if you want to pin a specific microphone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/Applications/Murmure.app/Contents/Helpers/murmure-rec &lt;span class="nt"&gt;--list-devices&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can then set the device by name in &lt;code&gt;~/.local/share/murmure/config&lt;/code&gt; (&lt;code&gt;MURMURE_DEVICE=MacBook Pro Microphone&lt;/code&gt;), or pick it in the Settings window. A name is safer than an index, which changes when you plug in a headset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Network, still
&lt;/h2&gt;

&lt;p&gt;Transcription never leaves the Mac. Murmure makes two kinds of requests: the one-time model download from Hugging Face, and the daily update check, which reads &lt;code&gt;appcast.xml&lt;/code&gt; from the latest GitHub release with &lt;a href="https://sparkle-project.org" rel="noopener noreferrer"&gt;Sparkle&lt;/a&gt;. No text, audio, identifier or system profile is sent. &lt;code&gt;MURMURE_CHECK_UPDATES=0&lt;/code&gt; turns update checks off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coming from 1.1.0
&lt;/h2&gt;

&lt;p&gt;1.1.0 can't update itself. Quit Murmure, download the DMG and drag the app over the old one. Your settings, vocabulary, corrections, history and model are kept, since they live in &lt;code&gt;~/.local/share/murmure&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Because the signature changed, macOS asks for the microphone again, and the old Accessibility entry still looks checked but no longer counts. In System Settings &amp;gt; Privacy &amp;amp; Security &amp;gt; Accessibility, remove the old Murmure entry (–), then add the new one (+). After that, future versions arrive on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;You need an Apple Silicon Mac on macOS 14 or later.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Download &lt;a href="https://github.com/croustibat/murmure/releases/latest/download/Murmure.dmg" rel="noopener noreferrer"&gt;Murmure.dmg&lt;/a&gt;, or&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;brew install --cask croustibat/tap/murmure&lt;/code&gt;, or&lt;/li&gt;
&lt;li&gt;build it yourself: &lt;code&gt;git clone https://github.com/croustibat/murmure.git &amp;amp;&amp;amp; cd murmure &amp;amp;&amp;amp; ./install.sh&lt;/code&gt; (Xcode required).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The menus are in French for now. Dictation is French by default and switches to English or any Whisper language in Settings, or with &lt;code&gt;MURMURE_LANG=en&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It's MIT licensed. Issues and PRs are welcome, and I'm especially curious how it behaves with other languages and microphones: &lt;a href="https://github.com/croustibat/murmure" rel="noopener noreferrer"&gt;github.com/croustibat/murmure&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Baptiste — freelance web dev for 20 years, building in Laravel/PHP and shipping small open-source tools.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>macos</category>
      <category>opensource</category>
      <category>swift</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Murmure: system-wide voice dictation for macOS that never leaves your Mac</title>
      <dc:creator>Baptiste Bouillot</dc:creator>
      <pubDate>Wed, 30 Sep 2026 13:14:06 +0000</pubDate>
      <link>https://dev.to/croustibat44/murmure-system-wide-voice-dictation-for-macos-that-never-leaves-your-mac-2ale</link>
      <guid>https://dev.to/croustibat44/murmure-system-wide-voice-dictation-for-macos-that-never-leaves-your-mac-2ale</guid>
      <description>&lt;p&gt;The dictation tools I tried on my Mac fell into two camps. The built-in one punctuates poorly. The good ones are paid, and they send your voice to a remote service.&lt;/p&gt;

&lt;p&gt;I wanted something smaller: press a shortcut, talk, and have the text appear in whatever app I'm in. No account, no subscription, no API key, nothing leaving the machine.&lt;/p&gt;

&lt;p&gt;So I built Murmure. Version 1.1.0 is the first public release: &lt;a href="https://github.com/croustibat/murmure" rel="noopener noreferrer"&gt;github.com/croustibat/murmure&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Murmure is a menu bar app. You press &lt;strong&gt;Cmd+Shift+E&lt;/strong&gt;, a small floating pill appears while it listens, you press the shortcut again, and the text is transcribed and pasted into the app that was active when you started. Mail, Slack, a terminal, an editor, a search field: it doesn't care.&lt;/p&gt;

&lt;p&gt;Transcription runs locally with &lt;a href="https://github.com/ggerganov/whisper.cpp" rel="noopener noreferrer"&gt;whisper.cpp&lt;/a&gt; and the &lt;code&gt;large-v3-turbo&lt;/code&gt; model. It takes about two seconds between the end of a sentence and the pasted text.&lt;/p&gt;

&lt;p&gt;There are two modes and no setting to switch between them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short press: toggle.&lt;/strong&gt; Press to start, press again to transcribe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hold: push-to-talk.&lt;/strong&gt; Keep the shortcut down while you speak, release to transcribe. The threshold between the two is 600 ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Escape cancels a dictation while it's listening. The recording is thrown away and nothing is pasted. Outside of a dictation, Escape keeps its normal role everywhere.&lt;/p&gt;

&lt;p&gt;The menu also keeps your last 10 dictations. One click copies one back to the clipboard, which is handy when a paste fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interesting bug: a script has no identity
&lt;/h2&gt;

&lt;p&gt;At its core, Murmure is a shell script: it records with &lt;code&gt;ffmpeg&lt;/code&gt;, sends the WAV to &lt;code&gt;whisper-cli&lt;/code&gt;, and pastes the result. The obvious setup is to bind that script to a shortcut and call it a day.&lt;/p&gt;

&lt;p&gt;Do that, and you mostly get subtitle credits.&lt;/p&gt;

&lt;p&gt;The reason is macOS privacy permissions (TCC). A bare script has no identity that macOS can attach a microphone permission to. So macOS never asks for permission, and it doesn't return an error either: it hands the process a &lt;strong&gt;silent audio stream&lt;/strong&gt;. Whisper, hearing nothing, does what it was trained to do on silent video and invents closing credits.&lt;/p&gt;

&lt;p&gt;The fix is the reason Murmure is an &lt;code&gt;.app&lt;/code&gt; bundle and not just a script. The bundle gets its own identity, macOS shows the microphone prompt, and the audio is real.&lt;/p&gt;

&lt;p&gt;The pipeline today looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cmd+Shift+E → Murmure.app (menu bar) → murmure.sh
                    ├─ ffmpeg (avfoundation) ──→ WAV 16 kHz mono
                    ├─ overlay (AppKit) ───────→ floating pill
                    ├─ whisper-cli ────────────→ text
                    ├─ corriger.pl ────────────→ vocabulary fixes
                    └─ pbcopy + Cmd+V ─────────→ active app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same identity question comes back with the Accessibility permission, which Murmure needs to send Cmd+V. macOS ties that permission to the app's signature. If you have an Apple developer certificate in your keychain, &lt;code&gt;install.sh&lt;/code&gt; signs the app with it and the permission survives updates. Without one, the app is signed ad hoc and recognized by its binary hash, so every rebuild means granting Accessibility again. The installer tells you when that happens, and it only rebuilds the app when something actually changed.&lt;/p&gt;

&lt;p&gt;Murmure also checks both permissions itself. If one is missing, the menu bar icon shows an exclamation mark and the first menu entry opens the right System Settings panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing technical words
&lt;/h2&gt;

&lt;p&gt;I dictate in French, and Whisper likes to translate developer jargon into French words that sound close. "Commit" comes back as "commis", "worktree" as "worktrade".&lt;/p&gt;

&lt;p&gt;Murmure has two files for that, both in &lt;code&gt;~/.local/share/murmure/&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;vocabulaire.txt&lt;/code&gt; is a short text given to Whisper as a prompt before transcription. It's not a list to fill up: Whisper re-reads it before every 30-second chunk and it shares 224 tokens with the end of the previous chunk. Past roughly 150 tokens, long dictations start losing the thread between chunks.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;corrections.txt&lt;/code&gt; is applied after transcription, one rule per line, case-insensitive, on whole words:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;commis|commit
redit|Redis
worktrade|worktree
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice the corrections file does most of the work. The vocabulary prompt is best kept for proper nouns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed, and what didn't help
&lt;/h2&gt;

&lt;p&gt;I spent some time trying to make transcription faster. On a short dictation the encoder still processes a full 30-second window, and that's most of the time. Shrinking that window (&lt;code&gt;-ac&lt;/code&gt;) made Whisper repeat itself. Greedy decoding, &lt;code&gt;--no-fallback&lt;/code&gt;, thread counts and VAD gained less than 10% or hurt the transcription. So Murmure keeps &lt;code&gt;whisper-cli&lt;/code&gt; defaults.&lt;/p&gt;

&lt;p&gt;If you want to measure on your own recordings, the repo includes a small benchmark script. Put a reference &lt;code&gt;.txt&lt;/code&gt; next to each &lt;code&gt;.wav&lt;/code&gt; and it also computes the error rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/bench.sh &lt;span class="nt"&gt;-n&lt;/span&gt; 5 dictee1.wav dictee2.wav
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Network
&lt;/h2&gt;

&lt;p&gt;Transcription never leaves the Mac. The only network request is an update check, at most once a day, that reads the latest release from the public GitHub API. No text, audio or identifier is sent. &lt;code&gt;MURMURE_CHECK_UPDATES=0&lt;/code&gt; in the config file turns it off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;You need an Apple Silicon Mac on macOS 14 or later, &lt;a href="https://brew.sh" rel="noopener noreferrer"&gt;Homebrew&lt;/a&gt;, and the Xcode command line tools.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/croustibat/murmure.git
&lt;span class="nb"&gt;cd &lt;/span&gt;murmure
./install.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The installer sets up &lt;code&gt;ffmpeg&lt;/code&gt; and &lt;code&gt;whisper-cpp&lt;/code&gt;, downloads the model once (about 550 MB, checked by SHA-256), builds the app and launches it. To update: &lt;code&gt;git pull &amp;amp;&amp;amp; ./install.sh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A note for non-French speakers: the docs and the menus are in French for now, and the default transcription language is French. Set &lt;code&gt;MURMURE_LANG=en&lt;/code&gt; (or &lt;code&gt;auto&lt;/code&gt;) in &lt;code&gt;~/.local/share/murmure/config&lt;/code&gt; and it applies to the next dictation.&lt;/p&gt;

&lt;p&gt;It's MIT licensed. Issues and PRs are welcome, and I'd like to hear how it behaves with other languages and other microphones: &lt;a href="https://github.com/croustibat/murmure" rel="noopener noreferrer"&gt;github.com/croustibat/murmure&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Baptiste — freelance web dev for 20 years, building in Laravel/PHP and shipping small open-source tools.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>macos</category>
      <category>opensource</category>
      <category>productivity</category>
      <category>swift</category>
    </item>
    <item>
      <title>PHP FFI on Apple Silicon: your ioctl call is lying to you</title>
      <dc:creator>Baptiste Bouillot</dc:creator>
      <pubDate>Sat, 15 Aug 2026 21:18:27 +0000</pubDate>
      <link>https://dev.to/croustibat44/php-ffi-on-apple-silicon-your-ioctl-call-is-lying-to-you-23kj</link>
      <guid>https://dev.to/croustibat44/php-ffi-on-apple-silicon-your-ioctl-call-is-lying-to-you-23kj</guid>
      <description>&lt;p&gt;I spent an evening building pseudo-terminal support for PHP and lost an hour of it to a bug that reports success. If you use FFI and ioctl anywhere near production, and your CI only runs on Linux, this one is worth ten minutes of your time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;PHP can already open a pseudo-terminal. &lt;code&gt;proc_open()&lt;/code&gt; accepts &lt;code&gt;['pty']&lt;/code&gt; descriptors, and on macOS you get a real &lt;code&gt;/dev/ttysNNN&lt;/code&gt; back.&lt;/p&gt;

&lt;p&gt;What you do not get is any control over the window size. There is no &lt;code&gt;ioctl()&lt;/code&gt; in PHP's standard library, so no &lt;code&gt;TIOCSWINSZ&lt;/code&gt;, so no &lt;code&gt;SIGWINCH&lt;/code&gt;. Interactive terminal programs render at whatever geometry they guess at startup, and they never find out the window changed. For anything that draws a full-screen UI — top, vim, an agent CLI — that is the difference between usable and useless.&lt;/p&gt;

&lt;p&gt;That single gap is why PHP projects that need to drive a terminal end up shipping a Node sidecar just to get node-pty.&lt;/p&gt;

&lt;p&gt;ext-ffi should close it. &lt;code&gt;openpty()&lt;/code&gt;, &lt;code&gt;login_tty()&lt;/code&gt; and &lt;code&gt;ioctl()&lt;/code&gt; are all sitting in libc. So I wrote the obvious binding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ffi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;FFI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;cdef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;&amp;lt;&amp;lt;&amp;lt;'C'
    struct winsize {
        unsigned short ws_row;
        unsigned short ws_col;
        unsigned short ws_xpixel;
        unsigned short ws_ypixel;
    };
    int openpty(int *amaster, int *aslave, char *name, void *termp, void *winp);
    int login_tty(int fd);
    int ioctl(int fd, unsigned long request, void *arg);
    int close(int fd);
C&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then set the size, fork, &lt;code&gt;login_tty()&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, and ask the child what it thinks its terminal looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;I asked for 30 rows by 120 columns. The child printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/dev/ttys018
0 2046
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tty is real. The size is not. And 2046 is not a plausible number of columns for anything — it is not a truncation of 120, not a byte-swap, not a field-order mistake. It is garbage.&lt;/p&gt;

&lt;p&gt;The part that cost me the hour: ioctl() returned 0. Success. No errno, no exception, nothing to check. The only way to know something went wrong was to ask the child.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isolating it
&lt;/h2&gt;

&lt;p&gt;Three hypotheses, in decreasing order of comfort:&lt;/p&gt;

&lt;p&gt;The struct winsize layout is wrong.&lt;br&gt;
The ioctl call itself is wrong.&lt;br&gt;
Something is wrong with the fork/exec path.&lt;/p&gt;

&lt;p&gt;There is a clean way to separate the first two. openpty() takes a struct winsize * as its fifth parameter — you can set the initial size at creation time without ever calling ioctl. And openpty() is not variadic.&lt;/p&gt;

&lt;p&gt;So: same struct, same child, same everything, three paths.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A — size set by openpty(winp), no ioctl at all&lt;/span&gt;
&lt;span class="nv"&gt;$rc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$ffi&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;openpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;FFI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="no"&gt;FFI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FFI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ws&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// B — ioctl declared with fixed arity&lt;/span&gt;
&lt;span class="c1"&gt;// int ioctl(int fd, unsigned long request, struct winsize *arg);&lt;/span&gt;
&lt;span class="nv"&gt;$ffi&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ioctl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$master&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;TIOCSWINSZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FFI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ws&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// C — ioctl declared variadic&lt;/span&gt;
&lt;span class="c1"&gt;// int ioctl(int fd, unsigned long request, ...);&lt;/span&gt;
&lt;span class="nv"&gt;$ffi&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ioctl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$master&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;TIOCSWINSZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FFI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ws&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ground truth is stty size run by a child attached to the pty — deliberately not &lt;code&gt;TIOCGWINSZ&lt;/code&gt;, because reading it back would go through the exact same suspect call.&lt;/p&gt;

&lt;p&gt;PHP 8.5.8, Darwin, arm64. Asking for 30 rows by 120 columns:&lt;/p&gt;

&lt;p&gt;A — openpty(winp), not variadic → child reports 30 120. Correct. Return value 0.&lt;br&gt;
B — ioctl declared with fixed arity → child reports 0 2046. Garbage. Return value 0.&lt;br&gt;
C — ioctl declared variadic → child reports 30 120. Correct. Return value 0.&lt;/p&gt;

&lt;p&gt;All three calls report success. Only one of them is telling the truth.&lt;/p&gt;

&lt;p&gt;The struct is fine. &lt;code&gt;openpty&lt;/code&gt; is fine. The declaration of &lt;code&gt;ioctl&lt;/code&gt; is not.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;ioctl is variadic in C:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;ioctl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;fildes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost every PHP + FFI snippet you will find online declares it with fixed arity instead — void *arg as the third parameter. On Linux x86-64 that is harmless: the variadic and non-variadic calling conventions agree for integers and pointers, so the value lands in the register the callee reads.&lt;/p&gt;

&lt;p&gt;Apple's ARM64 ABI does not agree. Apple diverges from the standard AAPCS64 here: in a variadic function, every variadic argument is passed on the stack, even though fixed arguments still travel in registers.&lt;/p&gt;

&lt;p&gt;So when you declare ioctl non-variadic, libffi builds a non-variadic call frame and places your pointer in register x2. The real ioctl — compiled as variadic — goes looking for it on the stack. It finds whatever was there, treats it as a struct winsize *, and copies eight bytes from it.&lt;/p&gt;

&lt;p&gt;If that address happens to be unmapped you get EFAULT and at least you know. If it happens to be readable — which is common — the call succeeds and writes nonsense. That is the 0 2046. It is not a corrupted value; it is a different piece of memory entirely.&lt;/p&gt;

&lt;p&gt;This is not PHP-specific. Chez Scheme hit the same wall on arm64 macOS (issue #745); any FFI over libffi can reproduce it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;One line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- int ioctl(int fd, unsigned long request, void *arg);
&lt;/span&gt;&lt;span class="gi"&gt;+ int ioctl(int fd, unsigned long request, ...);
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PHP's FFI parser accepts ... and libffi then uses ffi_prep_cif_var() with the correct fixed-argument count, which produces a Darwin-correct call frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check your own code
&lt;/h2&gt;

&lt;p&gt;If you have &lt;code&gt;FFI::cdef&lt;/code&gt; and &lt;code&gt;ioctl&lt;/code&gt; in the same file:&lt;/p&gt;

&lt;p&gt;Grep for the declaration. If the third parameter is typed rather than ..., you have this bug on Apple Silicon.&lt;br&gt;
Do not trust the return value. It will be 0.&lt;br&gt;
Write an assertion that a child process observes the effect, and run it in CI on macos-latest. Ubuntu alone gives you a false green — both declarations behave identically there.&lt;/p&gt;

&lt;p&gt;That last point is the one I would underline. This class of bug is invisible on the platform most CI runs on and silent on the platform most PHP developers write code on.&lt;/p&gt;

&lt;p&gt;The same reasoning applies to any variadic libc function you bind: open, fcntl, printf and friends. If the C header ends in ..., your cdef must too.&lt;/p&gt;
&lt;h2&gt;
  
  
  The package
&lt;/h2&gt;

&lt;p&gt;The pty work became php-pty — node-pty for PHP, MIT, nothing to compile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Croustibat\Pty\Pty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Pty&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'top'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// real TIOCSWINSZ, real SIGWINCH&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'q'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// non-blocking, for stream_select()&lt;/span&gt;
&lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;            &lt;span class="c1"&gt;// exit code&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three classes, about 300 lines. login_tty() so the child gets a real controlling terminal, which is what makes job control and Ctrl-C work — the thing proc_open cannot give you. CI on Ubuntu and macOS, PHP 8.2 to 8.4.&lt;/p&gt;

&lt;p&gt;CLI SAPI only, and it refuses to load elsewhere: pcntl_fork() duplicates the whole process, open database connections included. No Windows either — Windows has no pty, ConPTY is a different API.&lt;/p&gt;

&lt;p&gt;Three more things that cost me time&lt;/p&gt;

&lt;p&gt;Written down in case they save you some:&lt;/p&gt;

&lt;p&gt;PHP retries stream writes internally. It buffers writes in userspace and loops on the flush. On a pty master whose buffer is full, fwrite() then never returns, and no application-level timeout helps because you are stuck inside the call. stream_set_write_buffer($stream, 0) makes each fwrite() map to exactly one write(2) and hand EAGAIN straight back.&lt;/p&gt;

&lt;p&gt;Partial writes are the normal case, not an edge case. Pushing 1 MB through a pty master in 8 KB calls took 1,677 fwrite() calls instead of the 128 a full write would need — roughly 625 bytes accepted per call. Code that ignores the return value loses data thirteen times out of fourteen. When the truncation lands inside an escape sequence, your terminal prints the tail as literal text: a stray 7G on screen where a cursor move was meant.&lt;/p&gt;

&lt;p&gt;stty -echo is not stty raw. The first only silences the echo; the line discipline stays canonical and holds at most MAX_CANON bytes while it waits for a newline. Push half a megabyte with no \n through it and it jams solid.&lt;/p&gt;

&lt;p&gt;If you are doing anything with FFI and libc from PHP, check your variadic declarations. The bug that returns 0 is always the expensive one.&lt;/p&gt;

</description>
      <category>php</category>
      <category>ffi</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Running parallel Claude Code agents on Laravel with Orca: my setup and archive scripts</title>
      <dc:creator>Baptiste Bouillot</dc:creator>
      <pubDate>Tue, 28 Jul 2026 09:34:12 +0000</pubDate>
      <link>https://dev.to/croustibat44/running-parallel-claude-code-agents-on-laravel-with-orca-my-setup-and-archive-scripts-3ijn</link>
      <guid>https://dev.to/croustibat44/running-parallel-claude-code-agents-on-laravel-with-orca-my-setup-and-archive-scripts-3ijn</guid>
      <description>&lt;h1&gt;
  
  
  Running parallel Claude Code agents on Laravel with Orca: my setup and archive scripts
&lt;/h1&gt;

&lt;p&gt;David Hemphill wrote a great post about &lt;a href="https://davidhemphill.com/getting-started-with-conductor-using-laravel" rel="noopener noreferrer"&gt;getting started with Conductor using Laravel&lt;/a&gt; — how to wire up per-worktree setup and archive scripts so each parallel agent gets its own clean environment. I run the same idea, but with &lt;strong&gt;&lt;a href="https://www.onorca.dev/" rel="noopener noreferrer"&gt;Orca&lt;/a&gt;&lt;/strong&gt; instead of Conductor. This is my take, with the two scripts I actually use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: agents stepping on each other
&lt;/h2&gt;

&lt;p&gt;The moment you run more than one coding agent at a time, they start fighting over shared state. Two agents against the same database means one's &lt;code&gt;migrate:fresh --seed&lt;/code&gt; wipes the other's work mid-task. Two Laravel apps on the same local domain, the same queue, the same Reverb port — collisions everywhere.&lt;/p&gt;

&lt;p&gt;Git worktrees fix the &lt;em&gt;files&lt;/em&gt; part: each branch gets its own directory on disk. But a Laravel app isn't just files. It's a database, a signed local domain, a websocket port, a built frontend. If you don't isolate those too, "parallel" is a lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Orca gives you
&lt;/h2&gt;

&lt;p&gt;Orca is an agent development environment: it runs Claude Code, Codex, Gemini and other CLI agents in parallel, each in its own &lt;strong&gt;real git worktree&lt;/strong&gt;. One worktree = one branch = one workspace. You review the diff, ship a PR, then archive the worktree in one click.&lt;/p&gt;

&lt;p&gt;The part that makes it usable for Laravel is two lifecycle hooks in the repository settings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;Setup Script&lt;/strong&gt; that runs when a worktree is created, and&lt;/li&gt;
&lt;li&gt;an &lt;strong&gt;Archive Script&lt;/strong&gt; that runs right before a worktree is destroyed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Inside those scripts Orca hands you two environment variables I lean on heavily:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ORCA_ROOT_PATH&lt;/code&gt; — the path to the main repo (the "real" checkout, where my root &lt;code&gt;.env&lt;/code&gt; lives).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ORCA_WORKSPACE_NAME&lt;/code&gt; — the name of the current workspace/branch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both of my scripts fall back to plain git if those aren't set, so the exact same file also works when I run it by hand outside Orca.&lt;/p&gt;

&lt;h2&gt;
  
  
  The principle: one SQLite file and one Herd site per worktree
&lt;/h2&gt;

&lt;p&gt;My whole convention fits in one sentence: &lt;strong&gt;inside a worktree, the database is always an isolated SQLite file, never the project's shared database.&lt;/strong&gt; A local file that gets created with the worktree and deleted with it. No seed collisions between parallel agents, ever.&lt;/p&gt;

&lt;p&gt;Local serving is &lt;a href="https://herd.laravel.com/" rel="noopener noreferrer"&gt;Laravel Herd&lt;/a&gt;, one isolated, HTTPS-secured site per worktree. So agent A works on &lt;code&gt;myapp-feature-invoices.test&lt;/code&gt; while agent B is on &lt;code&gt;myapp-fix-webhooks.test&lt;/code&gt;, each on its own PHP version, its own database, its own everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup script
&lt;/h2&gt;

&lt;p&gt;This runs on worktree creation. I keep the real thing in &lt;code&gt;~/.scripts/laravel/worktree-setup.sh&lt;/code&gt; and reference it from each project with a tiny shim (more on that at the end). Here's what matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find the main repo, copy its &lt;code&gt;.env&lt;/code&gt;:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ORCA_ROOT_PATH&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;main_root&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORCA_ROOT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nv"&gt;main_root&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--git-common-dir&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi

if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$main_root&lt;/span&gt;&lt;span class="s2"&gt;/.env"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$main_root&lt;/span&gt;&lt;span class="s2"&gt;/.env"&lt;/span&gt; .env
&lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .env &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt; +a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The root &lt;code&gt;.env&lt;/code&gt; holds my real secrets and API keys, so every worktree inherits a working config with zero manual copy-paste. &lt;code&gt;set -a&lt;/code&gt; exports everything so I can use those values later in the script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build a unique, cert-safe site name:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;workspace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ORCA_WORKSPACE_NAME&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--abbrev-ref&lt;/span&gt; HEAD&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;workspace_slug&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workspace&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s#[^a-zA-Z0-9]#-#g'&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'[:upper:]'&lt;/span&gt; &lt;span class="s1"&gt;'[:lower:]'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$main_root&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;site_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;repo&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;workspace_slug&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;max_site_len&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;58
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;site_name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$max_site_len&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;ws_hash&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workspace&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;cksum&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nv"&gt;keep&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; max_site_len &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;ws_hash&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
    &lt;span class="nv"&gt;site_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$site_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-c1-&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$keep&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ws_hash&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 58-character cap is a gotcha I learned the hard way: the site name becomes the Common Name of the TLS certificate Herd generates, and a CN longer than 64 characters is invalid. Long branch names (&lt;code&gt;feature/some-very-descriptive-thing&lt;/code&gt;) blow past that, so past the limit I truncate and append a short &lt;code&gt;cksum&lt;/code&gt; hash to keep it unique.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Point &lt;code&gt;.env&lt;/code&gt; at the isolated SQLite DB and neutralize mail:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;sqlite_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/database/database.sqlite"&lt;/span&gt;
set_env &lt;span class="s2"&gt;"APP_URL"&lt;/span&gt;       &lt;span class="s2"&gt;"https://&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;site_name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.test"&lt;/span&gt;
set_env &lt;span class="s2"&gt;"DB_CONNECTION"&lt;/span&gt; &lt;span class="s2"&gt;"sqlite"&lt;/span&gt;
set_env &lt;span class="s2"&gt;"DB_DATABASE"&lt;/span&gt;   &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;sqlite_path&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
set_env &lt;span class="s2"&gt;"MAIL_MAILER"&lt;/span&gt;   &lt;span class="s2"&gt;"log"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;set_env&lt;/code&gt; is a small helper that updates a key if present or appends it — BSD &lt;code&gt;sed&lt;/code&gt;, since this is macOS.) Mail goes to the log so an agent can't accidentally send real email from a workspace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A deterministic Reverb port, only if the project uses Reverb:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; config/reverb.php &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'"laravel/reverb"'&lt;/span&gt; composer.json 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;hash_val&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$site_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;cksum&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nv"&gt;reverb_port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="m"&gt;8100&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;hash_val &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    set_env &lt;span class="s2"&gt;"REVERB_SERVER_PORT"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;reverb_port&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    set_env &lt;span class="s2"&gt;"REVERB_PORT"&lt;/span&gt;        &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;reverb_port&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    set_env &lt;span class="s2"&gt;"VITE_REVERB_PORT"&lt;/span&gt;   &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;reverb_port&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two worktrees can't both bind &lt;code&gt;8080&lt;/code&gt;. Deriving the port from a hash of the site name gives each worktree a stable, collision-resistant port without me tracking anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Link the site to Herd, isolate the PHP version, secure it:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;php_ver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'"php"[^,}]*'&lt;/span&gt; composer.json 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'8\.[0-9]+'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

herd &lt;span class="nb"&gt;link&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$site_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$php_ver&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;herd isolate &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$php_ver&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--site&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$site_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="k"&gt;fi
&lt;/span&gt;herd secure &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$site_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
herd restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PHP version is read straight from &lt;code&gt;composer.json&lt;/code&gt;, so a legacy project on 8.2 and a new one on 8.4 each get the right runtime automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fresh database, dependencies, and a built frontend:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sqlite_path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sqlite_path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;touch&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sqlite_path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# copy git-ignored MCP config so the agent has its tools&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$main_root&lt;/span&gt;&lt;span class="s2"&gt;/.mcp.json"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$main_root&lt;/span&gt;&lt;span class="s2"&gt;/.mcp.json"&lt;/span&gt; .mcp.json

herd composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-interaction&lt;/span&gt; &lt;span class="nt"&gt;--prefer-dist&lt;/span&gt; &lt;span class="nt"&gt;--optimize-autoloader&lt;/span&gt;
php artisan config:clear
php artisan key:generate &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--no-interaction&lt;/span&gt;
php artisan migrate:fresh &lt;span class="nt"&gt;--seed&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt;
php artisan storage:link 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true

&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; yarn.lock &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;yarn &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; yarn build
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; pnpm-lock.yaml &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;pnpm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pnpm build
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; package.json &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run build
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A couple of details I care about: I copy the git-ignored &lt;code&gt;.mcp.json&lt;/code&gt; from the root so the agent inherits my MCP servers, and I detect the frontend package manager from the lockfile instead of hardcoding &lt;code&gt;npm&lt;/code&gt;. I also gate private Composer auth (Flux Pro) behind "is it actually a dependency and are the creds present" so the script stays generic across projects.&lt;/p&gt;

&lt;p&gt;By the time this finishes, the agent opens onto a fully working &lt;code&gt;https://myapp-feature-x.test&lt;/code&gt; with its own seeded database. Zero manual setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The archive script
&lt;/h2&gt;

&lt;p&gt;This runs before Orca destroys the worktree. Its whole job is to undo what setup did — and to &lt;strong&gt;never&lt;/strong&gt; block the deletion, so every command is best-effort with &lt;code&gt;|| true&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-uo&lt;/span&gt; pipefail

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ORCA_ROOT_PATH&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;main_root&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORCA_ROOT_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nv"&gt;main_root&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--git-common-dir&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# same site name derivation as setup&lt;/span&gt;
&lt;span class="nv"&gt;workspace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ORCA_WORKSPACE_NAME&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--abbrev-ref&lt;/span&gt; HEAD&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;workspace_slug&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workspace&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s#[^a-zA-Z0-9]#-#g'&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'[:upper:]'&lt;/span&gt; &lt;span class="s1"&gt;'[:lower:]'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$main_root&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;site_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;repo&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;workspace_slug&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# (same 58-char truncation as setup)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workspace_slug&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"⚠️  Empty workspace name, cleanup aborted for safety."&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi

&lt;/span&gt;herd unsecure &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$site_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;herd unisolate &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$site_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;herd &lt;span class="nb"&gt;unlink&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$site_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/database/database.sqlite"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;herd restart &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It rebuilds the exact same &lt;code&gt;site_name&lt;/code&gt; with identical logic, then tears the Herd site down (unsecure → unisolate → unlink), deletes the SQLite file, and restarts Herd to purge the config. The empty-name guard matters: if the branch name ever resolves to nothing, I'd rather abort than run &lt;code&gt;herd unlink ""&lt;/code&gt; and nuke something I didn't mean to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one tip that makes this maintainable
&lt;/h2&gt;

&lt;p&gt;I don't paste these scripts into every project. The source of truth lives once in &lt;code&gt;~/.scripts/laravel/&lt;/code&gt;, and each repo carries a two-line shim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# scripts/worktree-setup.sh&lt;/span&gt;
&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.scripts/laravel/worktree-setup.sh"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Orca's repository settings I point the Setup Script at &lt;code&gt;scripts/worktree-setup.sh&lt;/code&gt; and the Archive Script at &lt;code&gt;scripts/worktree-archive.sh&lt;/code&gt;. Improve the shared script once and every project — and every future worktree — gets the fix. The scripts auto-detect PHP version, frontend manager, Reverb and Flux, so the same file really does work everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give it a try
&lt;/h2&gt;

&lt;p&gt;If you're running agents in parallel on Laravel, the isolation is the whole game: one SQLite file and one Herd site per worktree, created on setup and torn down on archive. Once that's in place, spinning up a third or fourth agent costs nothing, and none of them can corrupt each other's state.&lt;/p&gt;

&lt;p&gt;Credit where it's due — the pattern is straight out of &lt;a href="https://davidhemphill.com/getting-started-with-conductor-using-laravel" rel="noopener noreferrer"&gt;David Hemphill's Conductor post&lt;/a&gt;; I just adapted it to Orca and my stack. If you've got a sharper version of any of these scripts, I'd genuinely like to see it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Baptiste — freelance web dev for 20 years, building in Laravel/PHP and shipping small open-source tools.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Pinpoint 0.6.0: a crop tool, capture timers, and a changelog you can actually find</title>
      <dc:creator>Baptiste Bouillot</dc:creator>
      <pubDate>Wed, 15 Jul 2026 18:12:38 +0000</pubDate>
      <link>https://dev.to/croustibat44/pinpoint-060-a-crop-tool-capture-timers-and-a-changelog-you-can-actually-find-1c94</link>
      <guid>https://dev.to/croustibat44/pinpoint-060-a-crop-tool-capture-timers-and-a-changelog-you-can-actually-find-1c94</guid>
      <description>&lt;h1&gt;
  
  
  Pinpoint 0.6.0: a crop tool, capture timers, and a changelog you can actually find
&lt;/h1&gt;

&lt;p&gt;Quick refresher if this is your first time seeing it: &lt;strong&gt;Pinpoint&lt;/strong&gt; is a free, MIT-licensed macOS menu-bar app that lets you capture a region of your screen, drop numbered markers on it, and copy an annotated image &lt;em&gt;plus&lt;/em&gt; a structured text block that maps every marker to a position and a note. The point is to stop describing pixels to your AI agent in prose ("the button in the top-right, no, the &lt;em&gt;other&lt;/em&gt; one") and start pointing at them instead. Source and download are on &lt;a href="https://github.com/croustibat/Pinpoint" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;; there's a landing page at &lt;a href="https://pinpoint-ashy.vercel.app/" rel="noopener noreferrer"&gt;pinpoint-ashy.vercel.app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I just shipped &lt;strong&gt;0.6.0&lt;/strong&gt;, and this one is special for a reason I'll get to at the end. Here's what's new.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A crop tool in the editor
&lt;/h2&gt;

&lt;p&gt;You can now trim a capture right in the editor, with &lt;strong&gt;eight handles and a rule-of-thirds grid&lt;/strong&gt;. The part I care about most: &lt;strong&gt;existing markers and arrows are remapped into the cropped frame&lt;/strong&gt; — they don't drift, they stay pinned to the elements you pointed at.&lt;/p&gt;

&lt;p&gt;Why this matters for an agent workflow: the tighter the region you hand the model, the less irrelevant UI it has to reason about. Before, if I over-captured, I'd either re-shoot or send a noisy image. Now I capture generously, then crop down to exactly the elements I'm talking about — and because positions are stored as percentages of the image, the marker map stays correct after the crop.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. An optional capture timer
&lt;/h2&gt;

&lt;p&gt;There's now a &lt;strong&gt;3, 5, or 10-second countdown&lt;/strong&gt; before the shot fires. Press &lt;strong&gt;Esc&lt;/strong&gt; to cancel.&lt;/p&gt;

&lt;p&gt;This sounds small until you hit the case it solves: capturing UI that only exists while you're interacting with it. Hover states, open menus, dropdowns, tooltips — anything that disappears the moment you reach for a shortcut. Start the timer, arrange the UI, let it fire. It's the classic screenshot-timer trick, but wired into the same capture-to-clipboard flow so you don't leave the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A "What's New…" menu item
&lt;/h2&gt;

&lt;p&gt;Tiny, but I'd been meaning to add it: a &lt;strong&gt;"What's New…"&lt;/strong&gt; entry in the menu that opens the changelog. So you can always see what each release actually added without digging through GitHub tags. Dogfooding the idea that a tool should tell you about itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that makes this release special
&lt;/h2&gt;

&lt;p&gt;The crop tool and the capture timer weren't written by me. They were contributed by &lt;strong&gt;&lt;a href="https://github.com/ganuong11" rel="noopener noreferrer"&gt;@ganuong11&lt;/a&gt;&lt;/strong&gt; — the first external contributions to Pinpoint.&lt;/p&gt;

&lt;p&gt;I built this thing for myself, scratched my own itch, open-sourced it mostly so it wouldn't rot in a private repo. Watching someone else read the code, understand the marker-remapping model well enough to make crop preserve it, and send a clean PR — that's the moment an "open-source project" stops being a solo repo with a license file and becomes an actual project. It's a good feeling, and I wanted to say so publicly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you pair-program with an AI agent on a Mac, &lt;a href="https://github.com/croustibat/Pinpoint/releases" rel="noopener noreferrer"&gt;grab 0.6.0&lt;/a&gt; — it's notarized, signed, and everything stays local (captures never leave your machine). If you've got a better idea for the annotated-capture format, or a feature you want, &lt;strong&gt;issues and PRs are welcome&lt;/strong&gt; — there are a couple of good-first-issues open, and as of this release, merged PRs are officially a thing that happens here.&lt;/p&gt;

&lt;p&gt;Next on my list: refining the copied prompt format further. The percentage-based marker map plus a baked-in legend is my current answer to "what does a screenshot prompt need to contain for an agent to act without follow-up questions?" — but I'm still iterating, and I'd like to hear how you're solving it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Baptiste — freelance web dev for 20 years, building in Laravel/PHP and shipping small open-source tools.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>macos</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Stop describing pixels to your AI agent — annotate them instead</title>
      <dc:creator>Baptiste Bouillot</dc:creator>
      <pubDate>Wed, 15 Jul 2026 10:03:00 +0000</pubDate>
      <link>https://dev.to/croustibat44/stop-describing-pixels-to-your-ai-agent-annotate-them-instead-1ofp</link>
      <guid>https://dev.to/croustibat44/stop-describing-pixels-to-your-ai-agent-annotate-them-instead-1ofp</guid>
      <description>&lt;h1&gt;
  
  
  Stop describing pixels to your AI agent — annotate them instead
&lt;/h1&gt;

&lt;p&gt;If you pair-program with an AI agent — Claude Code, Codex, Cursor, whatever — you've lived this moment. You take a screenshot of your UI, paste it into the chat, and then you start typing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"See the button in the top-right? No, not that one, the &lt;em&gt;other&lt;/em&gt; one, next to the misaligned icon — make it full width on mobile and fix the icon while you're at it."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You're describing pixels in prose. And the agent, bless it, does its best to guess which of the six buttons in the screenshot you actually meant.&lt;/p&gt;

&lt;p&gt;It gets worse. A lot of chat UIs only keep the &lt;strong&gt;image&lt;/strong&gt; from your clipboard and silently throw away any text you copied alongside it. So even the careful caption you wrote never makes it through. Half your context evaporates between Cmd+C and Cmd+V.&lt;/p&gt;

&lt;p&gt;I hit this wall every single day. So I built a tool to fix it, and I open-sourced it. This post is about the underlying idea more than the tool — but I'll use the tool to make it concrete.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: point, don't describe
&lt;/h2&gt;

&lt;p&gt;Humans don't describe pixels to each other. We point. "This one." "Right here." We use our finger, a cursor, a circle drawn in the air.&lt;/p&gt;

&lt;p&gt;So the fix is obvious once you say it out loud: &lt;strong&gt;annotate the image with markers, and give each marker a stable identity the agent can reference.&lt;/strong&gt; Instead of "the button in the top-right," you say "marker 1," and the image literally has a numbered pin on that button.&lt;/p&gt;

&lt;p&gt;But a visual marker alone isn't enough, because the agent reads the image as a flat raster. It doesn't &lt;em&gt;know&lt;/em&gt; that the ring labeled "1" sits at 62% across and 48% down. So the second half of the idea is: &lt;strong&gt;alongside the annotated image, emit a structured text block that maps every marker to a position and a description&lt;/strong&gt;, then appends your actual instructions.&lt;/p&gt;

&lt;p&gt;Put together, a single paste carries everything the agent needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Annotated capture — 1280×800 px

An image is attached. Numbered (ringed) badges point to specific elements.
Markers (position in % of the image, top-left origin):

1. Primary CTA button · ~62 % × 48 %
2. Misaligned icon · ~12 % × 22 %

## Instructions
Make the CTA full-width on mobile and fix the icon alignment.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the agent doesn't guess. "Marker 1 is the primary CTA at roughly 62% × 48%. Marker 2 is the misaligned icon near the top-left." The ambiguity is gone, and your instructions are tied to concrete anchors instead of fuzzy spatial language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why percentages, not pixels
&lt;/h2&gt;

&lt;p&gt;A small but important detail: positions are expressed as &lt;strong&gt;percentages of the image&lt;/strong&gt;, with a top-left origin — not absolute pixel coordinates. Agents reason about layout relatively ("near the top-left," "centered horizontally") far more reliably than they handle raw pixel math, and percentages survive any downscaling the chat UI applies to your image before the model sees it. If the platform resizes your 1280px capture to 768px, "62%" still points at the same button. "794px" doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defending against UIs that drop your text
&lt;/h2&gt;

&lt;p&gt;Remember the chat UIs that keep only the image? There's a defense for that too: &lt;strong&gt;bake the legend into the picture.&lt;/strong&gt; Render the marker descriptions and instructions directly onto the exported image as a caption block. It's redundant with the clipboard text on purpose — if the platform keeps both, great; if it keeps only the image, the legend is still there, pixels and all. Belt and suspenders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it frictionless enough to actually use
&lt;/h2&gt;

&lt;p&gt;An idea like this only pays off if invoking it costs less than just typing the description would. If annotating takes 30 seconds of fiddling, you'll skip it. So the workflow has to be near-instant:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A global shortcut to capture a region (no app-switching, no window juggling).&lt;/li&gt;
&lt;li&gt;Click to drop markers — they auto-number themselves.&lt;/li&gt;
&lt;li&gt;A one-line note per marker.&lt;/li&gt;
&lt;li&gt;Copy. Done. Paste into your agent.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the bar: capture-to-clipboard in under ten seconds, from anywhere, without leaving what you were doing. On macOS that means a menu-bar app, &lt;code&gt;ScreenCaptureKit&lt;/code&gt; for native-resolution multi-display capture, and rebindable global shortcuts. It stays out of your way until you hit the hotkey.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool: Pinpoint
&lt;/h2&gt;

&lt;p&gt;The implementation of all this is &lt;strong&gt;Pinpoint&lt;/strong&gt; — a native macOS menu-bar app, built in SwiftUI + ScreenCaptureKit. Capture a region with a shortcut, drop numbered markers, add notes and arrows, and copy an annotated image plus the structured prompt above. Everything stays local; captures never leave your Mac. It's free, MIT-licensed, notarized and signed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Source &amp;amp; download: &lt;strong&gt;&lt;a href="https://github.com/croustibat/Pinpoint" rel="noopener noreferrer"&gt;https://github.com/croustibat/Pinpoint&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Landing: &lt;strong&gt;&lt;a href="https://pinpoint-ashy.vercel.app/" rel="noopener noreferrer"&gt;https://pinpoint-ashy.vercel.app/&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built it for myself and use it every day. The interesting part for me was less the Swift and more the format question: &lt;em&gt;what does a screenshot prompt actually need to contain for an agent to act on it without follow-up questions?&lt;/em&gt; The percentage-based marker map plus a baked-in legend is my current answer — but I'd genuinely like to hear how others are solving this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway, even if you never touch the tool
&lt;/h2&gt;

&lt;p&gt;The pattern generalizes beyond macOS and beyond this app: when you hand an agent an image, &lt;strong&gt;don't make it infer what you're pointing at.&lt;/strong&gt; Give it stable, named anchors and a machine-readable map of where they are. Whether you build that into your own workflow, a browser extension, or a CLI, the principle holds — pointing beats describing, and a structured map beats a paragraph of spatial prose.&lt;/p&gt;

&lt;p&gt;If you're shipping UI work with an AI agent, try replacing your next "the thing in the corner, no the other one" with a numbered marker. You'll feel the difference on the first paste.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Pinpoint is open source (MIT). Issues and PRs welcome — there are a couple of good-first-issues open. If you have a better prompt format for annotated captures, I want to see it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>macos</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Zed for Laravel - Complete Editor Setup in One Command</title>
      <dc:creator>Baptiste Bouillot</dc:creator>
      <pubDate>Fri, 03 Oct 2025 13:34:40 +0000</pubDate>
      <link>https://dev.to/croustibat44/zed-for-laravel-complete-editor-setup-in-one-command-3nk9</link>
      <guid>https://dev.to/croustibat44/zed-for-laravel-complete-editor-setup-in-one-command-3nk9</guid>
      <description>&lt;p&gt;Setting up a code editor for Laravel development can be time-consuming. After switching to &lt;a href="https://zed.dev" rel="noopener noreferrer"&gt;Zed&lt;/a&gt; - the lightning-fast editor from Atom's creators - I spent weeks configuring it perfectly for Laravel. &lt;/p&gt;

&lt;p&gt;Today, I'm open-sourcing that configuration as &lt;strong&gt;Zed for Laravel&lt;/strong&gt; - a one-command installer that sets up everything you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  🤔 What's Zed?
&lt;/h2&gt;

&lt;p&gt;If you haven't heard of Zed yet, it's a high-performance code editor built in Rust with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⚡ &lt;strong&gt;Blazing fast performance&lt;/strong&gt; - noticeably faster than VS Code&lt;/li&gt;
&lt;li&gt;🤝 &lt;strong&gt;Built-in multiplayer&lt;/strong&gt; - collaborate like in Google Docs&lt;/li&gt;
&lt;li&gt;🎯 &lt;strong&gt;Minimal &amp;amp; focused&lt;/strong&gt; - less bloat, more productivity&lt;/li&gt;
&lt;li&gt;🆓 &lt;strong&gt;Free &amp;amp; open-source&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Perfect for Laravel development once properly configured.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎯 The Problem
&lt;/h2&gt;

&lt;p&gt;When I first tried Zed for Laravel, I hit several roadblocks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Laravel Pint&lt;/strong&gt; wouldn't format on save&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Laravel snippets&lt;/strong&gt; out of the box&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blade syntax&lt;/strong&gt; needed configuration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Artisan commands&lt;/strong&gt; required manual terminal work&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extensions&lt;/strong&gt; had to be found and installed individually&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each issue took hours to solve. So I packaged all solutions into a single installer.&lt;/p&gt;

&lt;h2&gt;
  
  
  ✨ Features
&lt;/h2&gt;

&lt;h3&gt;
  
  
  🎨 Dual Theme System
&lt;/h3&gt;

&lt;p&gt;Choose your visual experience during installation:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default (One Dark)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clean, familiar colors&lt;/li&gt;
&lt;li&gt;Standard syntax highlighting&lt;/li&gt;
&lt;li&gt;Great for getting started&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Catppuccin blur (Custom)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;30+ syntax overrides optimized for PHP/Laravel&lt;/li&gt;
&lt;li&gt;Bold keywords for quick scanning&lt;/li&gt;
&lt;li&gt;Italic types for elegant distinction&lt;/li&gt;
&lt;li&gt;Bright green functions (Laravel helpers pop!)&lt;/li&gt;
&lt;li&gt;Reduced eye strain for long sessions&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  📝 130+ Smart Snippets
&lt;/h3&gt;

&lt;h4&gt;
  
  
  PHP Snippets (50+)
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// route + Tab&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'uri'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Controller&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'method'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// model + Tab&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ModelName&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;HasFactory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$fillable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// hasMany + Tab&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hasMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// request + Tab&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StoreUserRequest&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;FormRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'field'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'required|string|max:255'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full list: controllers, models, migrations, factories, tests, policies, jobs, events, listeners, middleware, seeders, and more.&lt;/p&gt;

&lt;h4&gt;
  
  
  Blade Snippets (40+)
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;!-- @foreach + Tab --&amp;gt;
@foreach ($items as $item)
    {{ $item-&amp;gt;name }}
@endforeach

&amp;lt;!-- component + Tab --&amp;gt;
&amp;lt;x-alert :type="success"&amp;gt;
    Message here
&amp;lt;/x-alert&amp;gt;

&amp;lt;!-- @auth + Tab --&amp;gt;
@auth
    &amp;lt;p&amp;gt;Welcome, {{ auth()-&amp;gt;user()-&amp;gt;name }}&amp;lt;/p&amp;gt;
@endauth
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All Blade directives included: &lt;code&gt;@if&lt;/code&gt;, &lt;code&gt;@forelse&lt;/code&gt;, &lt;code&gt;@switch&lt;/code&gt;, &lt;code&gt;@extends&lt;/code&gt;, &lt;code&gt;@section&lt;/code&gt;, &lt;code&gt;@include&lt;/code&gt;, &lt;code&gt;@can&lt;/code&gt;, &lt;code&gt;@error&lt;/code&gt;, and more.&lt;/p&gt;

&lt;h4&gt;
  
  
  Livewire 3 Snippets (30+)
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// livewire-component + Tab&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ComponentName&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$property&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'livewire.component-name'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// livewire-validate + Tab&lt;/span&gt;
&lt;span class="na"&gt;#[Validate('required|string|max:255')]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// livewire-on + Tab&lt;/span&gt;
&lt;span class="na"&gt;#[On('user-updated')]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handleUserUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Handle event&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full Livewire 3 support with properties, lifecycle hooks, events, wire directives, computed properties, and forms.&lt;/p&gt;

&lt;h3&gt;
  
  
  ⚡ Laravel Pint Integration
&lt;/h3&gt;

&lt;p&gt;Automatic code formatting on every save. The kit uses a reliable bash wrapper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"formatter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"external"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"-c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"cat &amp;gt; {buffer_path} &amp;amp;&amp;amp; ./vendor/bin/pint --quiet {buffer_path} &amp;amp;&amp;amp; cat {buffer_path}"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Respects your project's &lt;code&gt;pint.json&lt;/code&gt; configuration and Laravel coding standards.&lt;/p&gt;

&lt;h3&gt;
  
  
  🔧 25 Artisan Tasks
&lt;/h3&gt;

&lt;p&gt;Press &lt;code&gt;Cmd/Ctrl + Shift + T&lt;/code&gt; to access:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Development:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Artisan Serve&lt;/li&gt;
&lt;li&gt;Artisan Tinker&lt;/li&gt;
&lt;li&gt;Artisan Route List&lt;/li&gt;
&lt;li&gt;Artisan Queue Work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Database:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Artisan Migrate&lt;/li&gt;
&lt;li&gt;Artisan Migrate Fresh (with seeding)&lt;/li&gt;
&lt;li&gt;Artisan Migrate Rollback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Testing:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PHPUnit (all tests)&lt;/li&gt;
&lt;li&gt;PHPUnit (current file)&lt;/li&gt;
&lt;li&gt;PHPUnit (parallel)&lt;/li&gt;
&lt;li&gt;PHPUnit (with coverage)&lt;/li&gt;
&lt;li&gt;Pest (all tests)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Code Quality:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pint Format (all files)&lt;/li&gt;
&lt;li&gt;Pint Format (dirty files only)&lt;/li&gt;
&lt;li&gt;Pint Test (check without formatting)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Frontend:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NPM Dev (Vite)&lt;/li&gt;
&lt;li&gt;NPM Build (production)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Dependencies:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Composer Install&lt;/li&gt;
&lt;li&gt;Composer Update&lt;/li&gt;
&lt;li&gt;Composer Dump Autoload&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🔌 Auto-Installing Extensions
&lt;/h3&gt;

&lt;p&gt;Four essential extensions install automatically on first launch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PHP&lt;/strong&gt; - Full language server support&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laravel Blade&lt;/strong&gt; - Template syntax highlighting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Env&lt;/strong&gt; - .env file syntax support&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind CSS&lt;/strong&gt; - IntelliSense for Tailwind classes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No manual searching or clicking required.&lt;/p&gt;

&lt;h3&gt;
  
  
  ⌨️ Optimized Keybindings
&lt;/h3&gt;

&lt;p&gt;Laravel-friendly keyboard shortcuts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shortcut&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Cmd/Ctrl + Shift + I&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Format with Pint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Cmd/Ctrl + Shift + T&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run Artisan task&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Cmd/Ctrl + T&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Toggle terminal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Cmd/Ctrl + Shift + P&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Command palette&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Cmd/Ctrl + B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Toggle sidebar&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All familiar shortcuts that feel natural.&lt;/p&gt;

&lt;h2&gt;
  
  
  🚀 Installation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Quick Install
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;macOS / Linux:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/croustibat/zed-for-laravel/main/scripts/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Windows (PowerShell):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;irm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;https://raw.githubusercontent.com/croustibat/zed-for-laravel/main/scripts/install.ps1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;iex&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What Happens
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;✅ Backs up your existing Zed configuration&lt;/li&gt;
&lt;li&gt;🎨 Prompts you to choose a theme (Default or Dracula Pro)&lt;/li&gt;
&lt;li&gt;📋 Installs all configuration files&lt;/li&gt;
&lt;li&gt;📝 Installs 130+ snippets&lt;/li&gt;
&lt;li&gt;⚡ Configures Laravel Pint&lt;/li&gt;
&lt;li&gt;🔧 Sets up Artisan tasks&lt;/li&gt;
&lt;li&gt;🔌 Queues extensions for auto-install&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Time:&lt;/strong&gt; ~30 seconds&lt;/p&gt;

&lt;p&gt;Restart Zed and you're ready to code!&lt;/p&gt;

&lt;h3&gt;
  
  
  Manual Installation
&lt;/h3&gt;

&lt;p&gt;Prefer doing it manually? Full instructions in the &lt;a href="https://github.com/croustibat/zed-for-laravel/blob/main/docs/installation.md" rel="noopener noreferrer"&gt;installation guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  📖 Complete Documentation
&lt;/h2&gt;

&lt;p&gt;The kit includes comprehensive guides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/croustibat/zed-for-laravel/blob/main/docs/installation.md" rel="noopener noreferrer"&gt;Installation Guide&lt;/a&gt;&lt;/strong&gt; - Quick start and troubleshooting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/croustibat/zed-for-laravel/blob/main/docs/features.md" rel="noopener noreferrer"&gt;Features Overview&lt;/a&gt;&lt;/strong&gt; - Detailed feature breakdown&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/croustibat/zed-for-laravel/blob/main/docs/configuration.md" rel="noopener noreferrer"&gt;Configuration Guide&lt;/a&gt;&lt;/strong&gt; - Customization options&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/croustibat/zed-for-laravel/blob/main/docs/extensions.md" rel="noopener noreferrer"&gt;Extensions Guide&lt;/a&gt;&lt;/strong&gt; - Managing extensions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/croustibat/zed-for-laravel/blob/main/docs/themes.md" rel="noopener noreferrer"&gt;Theme Guide&lt;/a&gt;&lt;/strong&gt; - Theme comparison and customization&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  💡 Why I Built This
&lt;/h2&gt;

&lt;p&gt;After years with VS Code, I wanted something faster. Zed's performance is incredible, but the Laravel setup was fragmented across GitHub issues, forums, and documentation.&lt;/p&gt;

&lt;p&gt;I wanted a single source of truth - one command that sets up everything perfectly.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎯 Perfect For
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Laravel developers&lt;/strong&gt; wanting to try Zed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zed users&lt;/strong&gt; starting Laravel development&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Development teams&lt;/strong&gt; needing consistent setup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone tired&lt;/strong&gt; of slow editors or complex configuration&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🗺️ Roadmap
&lt;/h2&gt;

&lt;p&gt;Planning future releases:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v1.1&lt;/strong&gt; - Extended snippets&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pest-specific snippets&lt;/li&gt;
&lt;li&gt;Inertia.js (Vue/React)&lt;/li&gt;
&lt;li&gt;API Resource snippets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;v1.2&lt;/strong&gt; - More themes&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nord (arctic calm)&lt;/li&gt;
&lt;li&gt;Gruvbox (retro groove)&lt;/li&gt;
&lt;li&gt;Tokyo Night (cyberpunk)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;v2.0&lt;/strong&gt; - Advanced features&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Laravel Pint config generator&lt;/li&gt;
&lt;li&gt;Project scaffolding templates&lt;/li&gt;
&lt;li&gt;Team sync features&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🤝 Contributing
&lt;/h2&gt;

&lt;p&gt;This is an open-source project and contributions are welcome!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/croustibat/zed-for-laravel" rel="noopener noreferrer"&gt;https://github.com/croustibat/zed-for-laravel&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ways to contribute:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⭐ Star the repository&lt;/li&gt;
&lt;li&gt;🐛 Report bugs or issues&lt;/li&gt;
&lt;li&gt;💡 Suggest features&lt;/li&gt;
&lt;li&gt;🤝 Submit pull requests&lt;/li&gt;
&lt;li&gt;📖 Improve documentation&lt;/li&gt;
&lt;li&gt;💬 Help others in discussions&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🏁 Getting Started
&lt;/h2&gt;

&lt;p&gt;Ready to try it? Here's a quick start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Install Zed for Laravel&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/croustibat/zed-for-laravel/main/scripts/install.sh | bash

&lt;span class="c"&gt;# 2. Choose your theme (I recommend Dracula Pro for Laravel)&lt;/span&gt;

&lt;span class="c"&gt;# 3. Restart Zed&lt;/span&gt;

&lt;span class="c"&gt;# 4. Open your Laravel project&lt;/span&gt;

&lt;span class="c"&gt;# 5. Try a snippet: type 'route' and press Tab&lt;/span&gt;

&lt;span class="c"&gt;# 6. Save a PHP file to see Pint auto-format&lt;/span&gt;

&lt;span class="c"&gt;# 7. Press Cmd+Shift+T to run Artisan commands&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it! You're configured for Laravel development.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔗 Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/croustibat/zed-for-laravel" rel="noopener noreferrer"&gt;https://github.com/croustibat/zed-for-laravel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zed Editor:&lt;/strong&gt; &lt;a href="https://zed.dev" rel="noopener noreferrer"&gt;https://zed.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laravel:&lt;/strong&gt; &lt;a href="https://laravel.com" rel="noopener noreferrer"&gt;https://laravel.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laravel Pint:&lt;/strong&gt; &lt;a href="https://laravel.com/docs/pint" rel="noopener noreferrer"&gt;https://laravel.com/docs/pint&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🙏 Acknowledgments
&lt;/h2&gt;

&lt;p&gt;Thanks to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;Zed team&lt;/strong&gt; for creating an amazing editor&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Laravel community&lt;/strong&gt; for feedback and inspiration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catppuccin theme&lt;/strong&gt; creators for the beautiful color scheme&lt;/li&gt;
&lt;li&gt;All &lt;strong&gt;contributors&lt;/strong&gt; who help improve the project&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  💭 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Switching editors is always a bit scary. Will I lose productivity? Will I miss features? &lt;/p&gt;

&lt;p&gt;With Zed for Laravel, the transition is smooth. Everything works on day one. The speed improvement alone makes it worth trying.&lt;/p&gt;

&lt;p&gt;Give it a shot! If it's not for you, uninstalling is just deleting &lt;code&gt;~/.config/zed/&lt;/code&gt;. No commitment, no vendor lock-in.&lt;/p&gt;

&lt;p&gt;Happy coding! 🚀&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What editor do you use for Laravel development? Have you tried Zed? Let me know in the comments!&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; #laravel #php #zed #productivity #webdev #opensource #developer #coding&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>zed</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
