Skip to the tutorial
webmasterbulletin.net LizzardJS series

Serve any folder with PHP from a right-click

Build a Finder Quick Action that starts PHP's built-in web server in a file's folder and opens that file in Chrome. One right-click instead of a Terminal window, and no MAMP to configure.

Get the script
  • macOS with Automator
  • About 10 minutes
  • Needs PHP and Chrome

Why not just double-click the file?

Double-click a .php file and it opens in your editor. Drag it into Chrome and you get the raw source from a file:// address. PHP only runs behind a web server, and plenty of plain JavaScript needs one too: Chrome blocks fetch() calls and ES modules on file:// pages.

PHP ships with a small development server. Type php -S 127.0.0.1:8000 in a folder and that folder is live at http://127.0.0.1:8000. The Quick Action types that command for you, in the right folder, and opens the right page.

Binding to 127.0.0.1 keeps the server private to your Mac: other devices on your network can't reach it. It's a development server, so keep it off production.

What you need

A Mac with Automator
It comes with macOS, in your Applications folder.
PHP
macOS stopped shipping PHP with Monterey. Run php -v in Terminal to check. If it's missing, install it with Homebrew: brew install php.
Google Chrome
Or any other browser: you change one line of the script (see troubleshooting).

Build the Quick Action

  1. 1

    Create a Quick Action

    Open Automator, click New Document, choose Quick Action, then Choose.

  2. 2

    Tell it what it receives

    In the bar at the top of the workflow, set these two menus. Image and color can stay as they are.

    Workflow receives current
    files or folders
    in
    Finder.app
  3. 3

    Add a Run Shell Script action

    Type shell in the library search field on the left and drag Run Shell Script into the workflow. Then set its two menus:

    Shell
    /bin/zsh
    Pass input
    as arguments

    Check Pass input twice. Automator's default is to stdin, and with that setting the script never receives the file's path. You'd get a notification saying so instead of a page.

  4. 4

    Paste the script

    Delete the sample code in the action and paste this instead.

    Run Shell Script (zsh)
    export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
    
    # Show a macOS notification and stop
    fail() {
      osascript -e "display notification \"$1\" with title \"Serve with PHP\""
      exit 1
    }
    
    [[ -n "$1" ]] || fail "No file received. Set Pass input to: as arguments."
    command -v php >/dev/null || fail "PHP not found: brew install php"
    
    target="$1"
    if [[ -d "$target" ]]; then
      dir="$target"; file=""
      [[ -e "$dir/index.php" || -e "$dir/index.html" ]] ||
        fail "No index.php or index.html here. Right-click a file instead."
    else
      dir="${target:h}"; file="${target:t}"
    fi
    
    state="/tmp/phpserve-$(print -rn -- "$dir" | md5)"
    
    # Reuse this folder's server, but only if it's still our PHP process
    port=""
    if [[ -f "$state" ]]; then
      read -r port pid <"$state"
      ps -ww -p "$pid" -o command= 2>/dev/null |
        grep -qF -- "php -S 127.0.0.1:$port -t $dir" || port=""
    fi
    
    # Otherwise try ports from 8000 up until PHP says it started
    if [[ -z "$port" ]]; then
      pid=""
      for port in {8000..8099}; do
        # Write to a new file, so a busy port's log is never wiped
        new="/tmp/phpserve-$port.log.new"
        nohup php -S 127.0.0.1:$port -t "$dir" >"$new" 2>&1 </dev/null &
        pid=$!; disown
        for i in {1..30}; do
          grep -q "started" "$new" && break 2   # ours: done
          kill -0 $pid 2>/dev/null || break     # exited: port taken
          sleep 0.1
        done
        kill $pid 2>/dev/null; pid=""
        mv -f "$new" /tmp/phpserve-error.log
      done
      [[ -n "$pid" ]] || fail "PHP could not start. See /tmp/phpserve-error.log"
      # It becomes this port's log; PHP keeps writing to it
      mv -f "$new" "/tmp/phpserve-$port.log"
      print -r -- "$port $pid" >"$state"
    fi
    
    # Open the page, with spaces and accents encoded for the address bar
    page=$(php -r 'echo rawurlencode($argv[1]);' -- "$file")
    open -a "Google Chrome" "http://127.0.0.1:$port/$page"
  5. 5

    Save it

    Press ⌘S and name it Serve with PHP. Automator saves Quick Actions in ~/Library/Services, and Finder picks them up right away.

  6. 6

    Right-click a file

    In Finder, right-click any file and choose Quick Actions › Serve with PHP. Chrome opens http://127.0.0.1:8000/yourfile.php. Right-click a folder instead and Chrome opens its root, which serves index.php or index.html.

What the script does

It's short, but it handles the cases that make a one-line version annoying after the first day.

Finds Homebrew's PHP
Automator runs scripts with a minimal PATH that leaves out Homebrew. The first line adds /opt/homebrew/bin (Apple silicon) and /usr/local/bin (Intel).
Stops with a clear message
If the action receives no file, if PHP is missing, or if you right-click a folder with no index page, a macOS notification says what's wrong instead of Chrome opening an error page.
Works out the folder and the file
For a file, the server runs in its parent folder and Chrome opens the file. For a folder, the server runs in the folder itself and Chrome opens its root.
Reuses its own server
Each folder's port and server process are noted in a small file in /tmp. Right-click a second file in the same project and it opens on the same server, after checking that this PHP process is still the one serving that folder.
Finds a free port by asking PHP
It starts PHP on port 8000 and watches its log. If PHP reports that the port is taken, it tries 8001, 8002 and so on. Asking PHP itself catches any server already on a port, including ones started by other apps, and the busy port's log is left untouched. Several projects can run side by side.
Encodes the address
The file name goes through PHP's rawurlencode(), so mon fichier é.php opens as mon%20fichier%20%C3%A9.php.

Manage the servers

The servers keep running in the background until you stop them or restart your Mac. To stop them all right now, paste this line in Terminal:

Terminal
pkill -f "php -S 127.0.0.1"; rm -f /tmp/phpserve-*

To do it from Finder instead, add two more Quick Actions. They sit beside Serve with PHP when you right-click a file.

PHP servers…
Lists every PHP server running on your Mac, with its port and folder. Choose lines to open in Chrome or to stop, then click Go. Open goes back to the last page Chrome loaded from that server.
Stop PHP servers
Stops all of them at once, with no dialog, and shows a notification.

Finder can't put a menu inside Quick Actions, so PHP servers… opens its list as a dialog. You can select several lines at once.

Build each one like Serve with PHP: a Quick Action receiving files or folders in Finder.app, with a Run Shell Script action set to /bin/zsh and as arguments. Paste its script, then save it as PHP servers… or Stop PHP servers.

PHP servers…
Show all 105 lines
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"

# Show a macOS notification (the text is passed as an argument, so it's safe)
notify() {
  osascript -e 'on run argv' \
    -e 'display notification (item 1 of argv) with title "PHP servers"' \
    -e 'end run' "$1"
}

# Find every PHP built-in server: its process, address and folder
pids=() ports=() urls=() dirs=()
ps -ww -A -o pid= -o command= | while read -r pid cmd; do
  [[ ${${cmd%% *}:t} == php* && " $cmd " == *" -S "* ]] || continue
  addr=$(lsof -nP -a -p "$pid" -iTCP -sTCP:LISTEN -Fn 2>/dev/null |
    sed -n 's/^n//p' | head -1)
  [[ -n $addr ]] || continue
  host=${addr%:*}; port=${addr##*:}
  [[ $host == '*' || $host == 0.0.0.0 || $host == '[::]' ]] && host=127.0.0.1
  cwd=$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p')
  dir=$cwd
  if [[ " $cmd " == *" -t "* ]]; then
    # The folder follows -t; a router script may come after it
    dir=${cmd#* -t }
    while [[ $dir == *" "* && ! -d $dir && ! -d $cwd/$dir ]]; do
      dir=${dir% *}
    done
    [[ $dir == /* ]] || dir="$cwd/$dir"
  fi
  # Reopen the last page viewed, from the log Serve with PHP keeps
  page=""
  log="/tmp/phpserve-$port.log"
  [[ -r $log ]] && page=$(sed -n 's/.*\[200\]: GET \(\/[^ ]*\).*/\1/p' "$log" |
    grep -E '(\.php|\.html?|/)(\?.*)?$' | tail -1)
  pids+=$pid; ports+=$port; urls+="http://$host:$port${page:-/}"; dirs+=$dir
done

n=${#pids}
(( n )) || { notify "No PHP servers are running."; exit 0; }

# The folder you right-clicked, so its server is selected first
here=""
[[ -n $1 ]] && { [[ -d $1 ]] && here=${1%/} || here=${1:h}; }

(( n == 1 )) && prompt="1 PHP server is running:" || prompt="$n PHP servers are running:"
# A folder called public, www and so on is named with its parent
names=()
for d in $dirs; do
  [[ ${d:t} == (public|www|htdocs|web|html|dist|build) ]] && names+="${d:h:t}/${d:t}" ||
    names+=${d:t}
done

labels=() actions=() def=1
for i in {1..$n}; do
  prompt+=$'\n'"${ports[i]}   ${dirs[i]/#$HOME/~}"
  labels+="Open ${names[i]} in Chrome (port ${ports[i]})"; actions+="open $i"
  [[ ${dirs[i]} == "$here" ]] && def=$i
done
for i in {1..$n}; do
  labels+="Stop ${names[i]} (port ${ports[i]})"; actions+="stop $i"
done
(( n > 1 )) && { labels+="Stop all $n servers"; actions+="stopall"; }

# The picker: choose one or more lines, then Go
picked=$(osascript \
  -e 'on run argv' \
  -e 'activate' \
  -e 'set choices to items 3 thru -1 of argv' \
  -e 'set pre to item ((item 2 of argv) as integer) of choices' \
  -e 'set picked to choose from list choices with title "PHP servers" with prompt (item 1 of argv) default items {pre} OK button name "Go" cancel button name "Close" with multiple selections allowed' \
  -e 'if picked is false then return ""' \
  -e 'set text item delimiters to linefeed' \
  -e 'return picked as text' \
  -e 'end run' \
  "$prompt" "$def" "${labels[@]}")

stop=()
for line in ${(f)picked}; do
  k=${labels[(ie)$line]}
  (( k <= ${#labels} )) || continue
  case ${actions[k]} in
    open\ *) open -a "Google Chrome" "${urls[${actions[k]#open }]}" ;;
    stop\ *) stop+=${pids[${actions[k]#stop }]} ;;
    stopall) stop+=($pids) ;;
  esac
done
(( ${#stop} )) || exit 0

# Stop the chosen servers and wait up to 2 seconds for them to exit
stop=(${(u)stop})
kill $stop 2>/dev/null
for i in {1..20}; do
  alive=0
  for p in $stop; do kill -0 $p 2>/dev/null && alive=1; done
  (( alive )) || break
  sleep 0.1
done

# Forget servers that are gone, so Serve with PHP starts fresh ones
for f in /tmp/phpserve-*(N); do
  [[ $f == *.log* ]] && continue
  read -r p q _ <"$f"
  kill -0 "$q" 2>/dev/null || rm -f "$f"
done

(( ${#stop} == 1 )) && notify "Stopped 1 server." || notify "Stopped ${#stop} servers."
Stop PHP servers
Show all 27 lines
# Show a macOS notification and stop
notify() {
  osascript -e "display notification \"$1\" with title \"PHP servers\""
}

# Every PHP built-in server, matched on the program itself, never on this script
pids=()
ps -ww -A -o pid= -o command= | while read -r pid cmd; do
  [[ ${${cmd%% *}:t} == php* && " $cmd " == *" -S "* ]] && pids+=$pid
done
(( ${#pids} )) || { notify "No PHP servers were running."; exit 0; }

# Stop them and wait up to 2 seconds for them to exit
kill $pids 2>/dev/null
for i in {1..20}; do
  alive=0
  for p in $pids; do kill -0 $p 2>/dev/null && alive=1; done
  (( alive )) || break
  sleep 0.1
done

# Forget them, so Serve with PHP starts fresh ones
for f in /tmp/phpserve-*(N); do
  [[ $f == *.log* ]] || rm -f "$f"
done

(( ${#pids} == 1 )) && notify "Stopped 1 PHP server." || notify "Stopped ${#pids} PHP servers."

Both scripts pick out servers by the program that runs them, so they never stop themselves, and they also stop servers you started by hand in Terminal.

Give them shortcuts. In System Settings › Keyboard › Keyboard Shortcuts › Services, find the three actions under Files and Folders and assign key combinations. Select a file in Finder, press the keys, and the action runs.

If something doesn't work

Chrome shows 404 Not Found

Look at the address bar. If it ends with a bare / and no file name, the action didn't receive your file: set Pass input to as arguments (step 3) and save again. If your file name is there, check the file is still in that folder, then stop the servers and right-click it again so a fresh server starts.

Serve with PHP isn't in the Quick Actions menu

Right-click any file and choose Quick Actions › Customize…, then tick Serve with PHP. You'll also find the list in System Settings, under Extensions for Finder (General › Login Items & Extensions on recent macOS versions). Check too that step 2 says files or folders in Finder.app.

Nothing happens when I click it

Problems show up as notifications, so first check that Focus or Do Not Disturb isn't hiding them. PHP not found means the script can't see PHP: run which php in Terminal, and if the answer isn't in /opt/homebrew/bin or /usr/local/bin, add its folder to the export PATH line at the top of the script.

Chrome says the site can't be reached

The server isn't running any more. Its output is in /tmp/phpserve-8000.log (use the port number from the address bar). Open it with open /tmp/phpserve-8000.log to read the error.

macOS asks for access to Documents or Desktop

Allow it. The script needs to read the folder it serves, and macOS asks once per protected location.

I use Safari, Firefox or another browser

Change the browser name on the last line of the script, for example open -a "Safari" instead of open -a "Google Chrome". To use your default browser, delete -a "Google Chrome" and keep open with the address.

Put it to work on the LizzardJS lessons

LizzardJS is our free JavaScript series: start from a plain HTML form and end with a tested, bundled calculator. Every lesson runs in the browser, and every one is easier to follow from a local server than from a file:// page.