Altoplace

Configure Apache and PHP-FPM on macOS

Apple Computer.
Image by Pexels from Pixabay .

This post describes how to install and configure Apache and PHP-FPM to create a macOS local web development environment. I also describe how to use Dnsmasq, which adds a local DNS caching server to support using virtual hosts.

I am using Homebrew to install most of the software. Please go to their site for installation instructions.

This post is just about Apache, PHP, and Dnsmasq; I wrote about MySQL in a separate post. In this post, I also describe some tools that I wrote to manage Apache, PHP, and dnsmasq processes.

I updated this post for macOS Sonoma and Apple Silicon processors. Homebrew uses a different path, /opt/homebrew/bin, instead of /usr/local/bin, which is used for Intel processors. Be sure to correctly set your PATH for the processor version that you are using.

Using the Apache Event Module

There are multiple sites that describe how to install Apache and PHP on a Mac using Homebrew. But most of them describe using Apache with the pre-fork Multi-Processing Module (MPM). That’s understandable since Homebrew installs Apache with the pre-fork module active. But in the Linux world, the current, default module is the event module. This allows you to run PHP as a separate process, which is called on as needed. With the pre-fork module, PHP is built in (with mod_php) and is executed all the time even if PHP is not required to fulfill the request.

Running Apache with the pre-fork module is probably just fine while working with a local development environment. But I wanted to learn how to use it with the event module, running PHP as a separate process. By the way, you should check out “macOS 14.0 Sonoma Apache Setup: Multiple PHP Versions .” This is one of the best posts that I have seen about setting up a local website development environment. It has inspired much of what I have done to setup my local development environment. It also uses Homebrew to install most of the software.

Install Apache with the brew command

Let’s get started; the first step is to install Apache, using Homebrew. Please install Homebrew and verify your installation if you haven’t done so. First, verify that Homebrew is ready to go. Next, install Apache (and a number of dependencies). Finally, run a couple of commands to verify the installation (restart your terminal session to ensure that you are using the Homebrew version):

$ brew --version
Homebrew 4.2.17

$ brew doctor
Your system is ready to brew.

$ brew install httpd

$ which -a httpd
/opt/homebrew/bin/httpd
/usr/sbin/httpd

$ httpd -v
Server version: Apache/2.4.59 (Unix)
Server built:   Apr  3 2024 12:22:45

$ httpd -V | grep MPM
Server MPM:     prefork
Use the which command to verify that the Homebrew version of Apache is first in your PATH (you may have to restart your terminal session).

The last command shows that Apache was installed with the prefork module enabled.

Great. Now, let’s start it up and test it with Safari:

$ brew services start httpd
==> Successfully started `httpd` (label: homebrew.mxcl.httpd)

$ brew services info httpd
httpd (homebrew.mxcl.httpd)
Running: ✔
Loaded: ✔
Schedulable: ✘
User: george
PID: 36037

(The first time you run the brew services command, it will add the Homebrew-services tap.) Click on: http://localhost:8080 to test that Apache is running. You should see It Works! in your Safari browser. The brew services info httpd also shows that Apache is loaded and running. It also shows the user and process ID.

You had to specify the non-standard 8080 port number as part of the URL. That’s because Homebrew does not like using sudo, which is required if you want to start up Apache with the standard port 80 configuration. That’s called a privileged port and requires root privileges.

If you don’t see “It Works!”, then you need to do some troubleshooting. Make sure that httpd is actually running. I have seen cases where the brew services command said that it started, but it either didn’t start or died right away (later on I will describe my a2ctl tool for starting and stopping httpd; it does additional error checking). Let’s verify that the httpd processes are running:

$ ps -x | egrep httpd | sed '/grep/d'
11623 ??         0:00.05 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND
11626 ??         0:00.00 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND
11627 ??         0:00.00 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND
11628 ??         0:00.00 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND
11629 ??         0:00.00 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND
11630 ??         0:00.00 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND
11702 ??         0:00.00 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND
11704 ??         0:00.00 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND

If you do not see a list of httpd processes, or you are still having issues, please check the Apache error log file.

tail -f /opt/homebrew/var/log/httpd/error_log

It will usually show what went wrong.

As you may have guessed, this is how to stop Apache:

$ brew services stop httpd 
Stopping `httpd`... (might take a while)
==> Successfully stopped `httpd` (label: homebrew.mxcl.httpd)

$ brew services info httpd
httpd (homebrew.mxcl.httpd)
Running: ✘
Loaded: ✘
Schedulable: ✘

Switching Apache to the event module and port 80

Using port 80

Do the following to switch to port 80:

$ cd /opt/homebrew/etc/httpd

$ mkdir -p ~/orig/opt/homebrew/etc/httpd

$ cp httpd.conf ~/orig/opt/homebrew/etc/httpd

# Use your favorite text editor ...
$ vi httpd.conf

$ diff ~/orig/opt/homebrew/etc/httpd/httpd.conf .
52c52
< Listen 8080
---
> Listen 80

223a224
> ServerName localhost

$ brew services start httpd
==> Successfully started `httpd` (label: homebrew.mxcl.httpd)

Now, go to http://localhost (dropping the 8080) and you should see It Works! again.

My testing on macOS Sonoma shows that using the brew services command to start and stop httpd works just fine, using port 80 (a privileged port). So you can use (no sudo required):
$ brew services start httpd  
$ brew services stop httpd

I wrote a shell script called a2ctl to start, stop, or restart Apache. It does use sudo to start and stop httpd. It provides additional error checking to verify that httpd has successfully started or stopped. It also starts httpd with the root user. You can use the command to restart httpd.

Create a file named a2ctl in your user bin directory and add the following content (be sure to make it executable, chmod +x a2ctl):

Make sure that you have stopped Apache with the above brew services command before trying to use a2ctl.
#!/bin/zsh

# Usage: a2ctl start|stop|restart|status

error_exit()
{
  echo -e "$1" 1>&2
  exit 1
}

# Usage
if [[ ! $# -eq 1 || ! ($1 == "start" || $1 == "stop" || $1 == "restart" || $1 == "status") ]]
then
  error_exit "Usage: a2ctl start|stop|restart|status"
else
  action="$1"
fi

pwait()
{
  process=$1
  action=$2
  count=0

  if [[ $action == "stop" ]]
  then
    until ! pgrep -q $process || [[ $count -gt 5 ]]
    do
      echo "waiting for $process to stop $count ..."
      sleep 1
      ((count++))
    done
    if ! pgrep -q $process; then
      echo "$process stopped OK ..."
    else
      error_exit "$process failed to stop"
    fi
  else
    # action is start
    sleep 1 # give process time to die for configuration file errors ...
    until pgrep -q $process || [[ $count -gt 5 ]]
    do
      echo "waiting for $process to start $count ..."
      sleep 1
      ((count++))
    done
    if pgrep -q $process; then
      echo "$process started OK ..."
    else
      error_exit "$process failed to start"
    fi
  fi
}

a2start()
{
  if pgrep -q httpd; then
    echo "Apache is already started ..."
    exit 0
  fi

  if [ ! -f "/opt/homebrew/opt/httpd/homebrew.mxcl.httpd.plist" ]; then
    error_exit "Cannot start Apache -- homebrew.mxcl.httpd.plist is missing ..."
  fi

  sudo cp /opt/homebrew/opt/httpd/homebrew.mxcl.httpd.plist /Library/LaunchDaemons/
  if [ "$?" != "0" ]; then
    error_exit "homebrew.mxcl.httpd.plist copy failed ..."
  fi

  # Ensure that httpd is not loaded already
  sudo launchctl unload /Library/LaunchDaemons/homebrew.mxcl.httpd.plist > /dev/null 2>&1
  sleep 1
  sudo launchctl load /Library/LaunchDaemons/homebrew.mxcl.httpd.plist > /dev/null 2>&1
  pwait httpd start
}

a2stop()
{
  sudo launchctl unload /Library/LaunchDaemons/homebrew.mxcl.httpd.plist > /dev/null 2>&1
  pwait httpd stop

  # Don't start automatically
  sudo rm -f /Library/LaunchDaemons/homebrew.mxcl.httpd.plist
}

a2restart()
{
  a2stop
  a2start
}

a2status()
{
  # Get status about running httpd processes
  ps -axo user,pid,start,etime,time,nice,vsz,rss,command |\
    egrep '^USER|httpd' | sed '/grep/d'
}

case $action in
  "start")
    a2start
    ;;
  "stop")
    a2stop
    ;;
  "restart")
    a2restart
    ;;
  "status")
    a2status
    ;;
  *)
    # Should never happen ...
    error_exit "Invalid action ..."
    ;;
esac

exit 0

As noted by the Usage comment, it is very easy to use:

$ a2ctl
Usage: a2ctl start|stop|restart|status

$ a2ctl start
httpd started OK ...

$ a2ctl restart
httpd stopped OK ...
httpd started OK ...

$ a2ctl status
USER               PID STARTED     ELAPSED      TIME NI      VSZ    RSS COMMAND
root             72535  2:48PM       00:09   0:00.03  0 408647744   4736 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND
_www             72537  2:48PM       00:09   0:00.00  0 408536064   2288 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND
_www             72538  2:48PM       00:09   0:00.00  0 408536064   2288 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND
_www             72539  2:48PM       00:09   0:00.00  0 408529920   2368 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND
_www             72540  2:48PM       00:09   0:00.00  0 408528896   2320 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND
_www             72541  2:48PM       00:09   0:00.00  0 408536064   2320 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND

$ a2ctl stop
httpd stopped OK ...

$ a2ctl status
USER               PID STARTED     ELAPSED      TIME NI      VSZ    RSS COMMAND

Once started, Apache will start automatically when the macOS system is restarted. If stopped, Apache will not start when the system restarts. If stopped or running, restart will (re)start Apache.

Switching to the event module

Make the following changes to switch Apache to the event module:

$ cd /opt/homebrew/etc/httpd

$ vi httpd.conf

$ diff ~/orig/opt/homebrew/etc/httpd/httpd.conf .
 o o o
66,67c66,67
< #LoadModule mpm_event_module lib/httpd/modules/mod_mpm_event.so
< LoadModule mpm_prefork_module lib/httpd/modules/mod_mpm_prefork.so
---
> LoadModule mpm_event_module lib/httpd/modules/mod_mpm_event.so
> #LoadModule mpm_prefork_module lib/httpd/modules/mod_mpm_prefork.so

488c489
< #Include /opt/homebrew/etc/httpd/extra/httpd-mpm.conf
---
> Include /opt/homebrew/etc/httpd/extra/httpd-mpm.conf

I commented out the prefork module and uncommitted the event module. Uncommenting the Include httpd-mpm.conf file is optional, but it allows updating the number of httpd event servers, etc. This is the default event MPM configuration defined in this file:

$ cd /opt/homebrew/etc/httpd/extra

$ cat httpd-mpm.conf
 o o o
# event MPM
# StartServers: initial number of server processes to start
# MinSpareThreads: minimum number of worker threads which are kept spare
# MaxSpareThreads: maximum number of worker threads which are kept spare
# ThreadsPerChild: constant number of worker threads in each server process
# MaxRequestWorkers: maximum number of worker threads
# MaxConnectionsPerChild: maximum number of connections a server process serves
#                         before terminating
<IfModule mpm_event_module>
    StartServers             3
    MinSpareThreads         75
    MaxSpareThreads        250
    ThreadsPerChild         25
    MaxRequestWorkers      400
    MaxConnectionsPerChild   0
</IfModule>
 o o o

Restart httpd and verify that you can still access localhost . You can also verify that the event module is running:

$ httpd -V | grep MPM
Server MPM:     event

Change Document Root to the Local User

Because I am setting up a Development environment (don’t do this on a production system), I will move the Document Root to the local user Sites directory. Make the following changes to httpd.conf (changing george to your local user). Also, while this file is open, you can make a few additional changes to support using PHP-FPM (described in the next section):

# Use PHP-FPM ...

131c131
< #LoadModule proxy_module lib/httpd/modules/mod_proxy.so
---
> LoadModule proxy_module lib/httpd/modules/mod_proxy.so
135c135
< #LoadModule proxy_fcgi_module lib/httpd/modules/mod_proxy_fcgi.so
---
> LoadModule proxy_fcgi_module lib/httpd/modules/mod_proxy_fcgi.so

# Set local user ...

192,193c192,193
< User _www
< Group _www
---
> User george
> Group staff

# Change the document root to /Users/user/Sites

247,248c248,249
< DocumentRoot "/opt/homebrew/var/www"
< <Directory "/opt/homebrew/var/www">
---
> DocumentRoot "/Users/george/Sites"
> <Directory "/Users/george/Sites">

268c269
<     AllowOverride None
---
>     AllowOverride All

281c282,289
<     DirectoryIndex index.html
---
>     DirectoryIndex index.php index.html

# and add this block right after the dir_module block ...

# Run php-fpm via proxy_fcgi
<IfModule proxy_fcgi_module>
    <FilesMatch ".php$">
        SetHandler "proxy:unix:/opt/homebrew/var/run/php-fpm.sock|fcgi://localhost"
    </FilesMatch>
</IfModule>

I used george as an example user. Create the Sites directory if it does not exist. Create an index.html file in the Sites directory:

$ cat index.html
<h1>Our Local User Web Root</h1>

Restart Apache with the above changes:

$ a2ctl restart
Password:
httpd stopped OK ...
httpd started OK ...

Go to your localhost . You should see “Our Local User Web Root” in your browser (you may have to click on refresh first).

Check the Apache error log if it doesn’t start up OK. At this point Apache is ready to run PHP. We don’t have to touch it again. We can install PHP, change PHP versions, etc., and Apache will keep talking to the currently running PHP version. So let’s move on to our PHP install.

Setting up PHP-FPM to work with Apache

As of this writing, the newest version of PHP is version 8.3. I am using version 8.2 at my Pair Networks host (version 8.3 is available). The following instructions will describe how to install and configure PHP versions 8.2 and 8.3. Older versions (7.x) are not supported by Homebrew (unless you use a private tap ). I could use the brew services command to start and stop the PHP-FPM service. But I opted to write a shell script to start and stop PHP-FPM. I can also easily switch PHP versions with my shell script.

I will also update the PHP-FPM configuration file to use a Unix socket to talk to Apache (which is ready to go on the Apache side). The PHP brew installation defaults to using a TCP/IP socket, but the Unix socket approach has less overhead. You can read about what’s the difference between a Unix socket and a TCP/IP socket . First install PHP version 8.2 (it will install a lot of dependences) and then install PHP version 8.3 (the current default PHP as of this writing):

$ brew install php@8.2
$ brew install php

$ php -v
PHP 8.3.6 (cli) (built: Apr 10 2024 14:21:20) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.3.6, Copyright (c) Zend Technologies
    with Zend OPcache v8.3.6, Copyright (c), by Zend Technologies

# Verify starting and stopping PHP
$ brew services start php
==> Successfully started `php` (label: homebrew.mxcl.php)

$ brew services stop php
Stopping `php`... (might take a while)
==> Successfully stopped `php` (label: homebrew.mxcl.php)

# Switch to PHP version 8.2 (I would ignore the PATH message):
$ brew unlink php && brew link --overwrite --force php@8.2
Unlinking /opt/homebrew/Cellar/php/8.3.6... 24 symlinks removed.
Linking /opt/homebrew/Cellar/php@8.2/8.2.18... 25 symlinks created.

If you need to have this software first in your PATH instead consider running:
  echo 'export PATH="/opt/homebrew/opt/php@8.2/bin:$PATH"' >> ~/.profile
  echo 'export PATH="/opt/homebrew/opt/php@8.2/sbin:$PATH"' >> ~/.profile

$ php -v
PHP 8.2.18 (cli) (built: Apr  9 2024 18:46:23) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.2.18, Copyright (c) Zend Technologies
    with Zend OPcache v8.2.18, Copyright (c), by Zend Technologies

$ brew services start php@8.2
==> Successfully started `php@8.2` (label: homebrew.mxcl.php@8.2)

$ brew services info php@8.2
php@8.2 (homebrew.mxcl.php@8.2)
Running: ✔
Loaded: ✔
Schedulable: ✘
User: george
PID: 21250

$ brew services stop php@8.2
Stopping `php@8.2`... (might take a while)
==> Successfully stopped `php@8.2` (label: homebrew.mxcl.php@8.2)

Starting and Stopping PHP-FPM

You can continue using the brew services command to start and stop PHP-FPM. I prefer having a little more control for starting and stopping, including verifying that PHP-FPM actually did start. I have seen cases where the brew services command will report a successful start, but PHP-FPM did not start (or died immediately). I also wanted to implement a convenient way to switch PHP versions. Therefore, I wrote a shell script called phpctl to start, stop, and restart PHP-FPM. I also included a status action to get information about the running PHP-FPM processes. But before we get into the details about phpctl, I will update the PHP-FPM configuration for Apache (and some additional tweaks).

First, change directory to /opt/homebrew/etc/php/8.2/php-fpm.d. The default PHP-FPM configuration is in www.conf. You will want to save a copy of that file in a safe place because it has a lot of useful comments in it, but I am going to filter out all the commented lines and make the Unix proxy changes and point to my local user (which you need to update):

$ cd /opt/homebrew/etc/php/8.2/php-fpm.d

$ pwd
/opt/homebrew/etc/php/8.2/php-fpm.d

$ mkdir -p ~/orig/opt/homebrew/etc/php/8.2/php-fpm.d

$ cp www.conf ~/orig/opt/homebrew/etc/php/8.2/php-fpm.d

# Strip out all the comments ...
$ cat www.conf | egrep -v "^;" | egrep -v "^$" > tmpfile
$ cat tmpfile
[www]
user = _www
group = _www
listen = 127.0.0.1:9000
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

$ mv tmpfile www.conf

Update the user, group, and listen directives in the www.conf file to use the local user and a Unix socket.

$ vi www.conf
$ cat www.conf
[www]
user = george
group = staff
listen = /opt/homebrew/var/run/php-fpm.sock
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

Now I can test that PHP-FPM is working with Apache. You will need to first stop (if not stopped already) and then start PHP-FPM with the brew services command. Next, create a simple info.php PHP script to test that everything is working (make sure that Apache is running):

$ brew services stop php@8.2
Stopping `php@8.2`... (might take a while)
==> Successfully stopped `php@8.2` (label: homebrew.mxcl.php@8.2)

$ brew services start php@8.2
==> Successfully started `php@8.2` (label: homebrew.mxcl.php)

$ cd ~/Sites

$ vi info.php

$ cat info.php
<?php
  phpinfo();
?>

Go to http://localhost/info.php . You should see the PHP info screen with lots and lots of information about your running PHP installation. Note that the Server API shows FPM/FastCGI. If all is good, then you can move on to making the same changes for the latest brew php version, 8.3. Do the following:

$ brew services stop php@8.2
Stopping `php@8.2`... (might take a while)
==> Successfully stopped `php@8.2` (label: homebrew.mxcl.php@8.2)

$ brew unlink php && brew link --overwrite --force php@8.3
Unlinking /opt/homebrew/Cellar/php/8.3.6... 0 symlinks removed.
Unlinking /opt/homebrew/Cellar/php@8.2/8.2.18... 25 symlinks removed.
Linking /opt/homebrew/Cellar/php/8.3.6... 24 symlinks created.

$ php -v
PHP 8.3.6 (cli) (built: Apr 10 2024 14:21:20) (NTS)

$ cd /opt/homebrew/etc/php/8.3/php-fpm.d
$ mkdir -p ~/orig/opt/homebrew/etc/php/8.3/php-fpm.d
$ cp www.conf ~/orig/opt/homebrew/etc/php/8.3/php-fpm.d
$ cat www.conf | egrep -v "^;" | egrep -v "^$" > tmpfile
$ cat tmpfile
[www]
user = _www
group = _www
listen = 127.0.0.1:9000
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

$ mv tmpfile www.conf
$ vi www.conf
$ cat www.conf
[www]
user = george
group = staff
listen = /opt/homebrew/var/run/php-fpm.sock
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

$ brew services start php
==> Successfully started `php` (label: homebrew.mxcl.php)

$ brew services info php
php (homebrew.mxcl.php)
Running: ✔
Loaded: ✔
Schedulable: ✘
User: george
PID: 23593

Now you can go to http://localhost/info.php and verify that the PHP version 8.3 service is running.

Using phpctl instead of the brew services command

I wrote a shell script, phpctl, to have a little finer control for stopping and starting PHP-FPM. I can use it to switch the running version of PHP-FPM. Create an executable phpctl file in your bin directory and add the following content (be sure to stop php with the brew services command before using phpctl):

#!/bin/zsh

# Usage: phpctl start [8.2|8.3] -- starts PHP version 8.x
#          (defaults to currently linked version)
#        phpctl restart -- restarts the currently linked version
#        phpctl stop -- stops the currently linked and running version
#        phpctl status -- get info about running php-fpm processes
#
#        The start action will unlink the current version and link
#        the requested version if different from the linked version.
#        The restart and stop actions only work with the
#        currently linked version.

error_exit()
{
  echo -e "$1" 1>&2
  exit 1
}

# Get the currently linked PHP version ...
linked="php@$(/opt/homebrew/bin/php -v | grep ^PHP | cut -d' ' -f2 | cut -d'.' -f1,2)"

# Get command parameters ...
if [[ ! $# -ge 1 || ! ($1 == "start" || $1 == "stop" || $1 == "restart" || $1 == "status" ) ]]; then
  error_exit "Usage:\tphpctl start [8.2|8.3]\n\tphpctl restart\n\tphpctl stop\n\tphpctl status"
else
  action="$1"
  if [[ -z $2 ]]; then
    # Default to the linked version
    version=${linked}
  elif [[ ! ($2 == "8.2" || $2 == "8.3") ]]; then
    error_exit "Version $2 is not supported."
  else
    version="php@$2"
  fi
fi

# plist file name format can change (e.g., php vs php@7.4)
plfile=$(find /opt/homebrew/opt/${linked}/homebrew.mxcl.php*.plist)
if [[ $? -ne 0 ]]; then
  error_exit "Did not find linked ${linked} plist file ..."
fi

pwait()
{
  process=$1
  action=$2
  count=0

  if [[ $action == "stop" ]]
  then
    until ! pgrep -q $process || [[ $count -gt 5 ]]
    do
      echo "waiting for $process to stop $count ..."
      sleep 1
      ((count++))
    done
    if ! pgrep -q $process; then
      echo "$process stopped OK ..."
    else
      error_exit "$process failed to stop"
    fi
  else
    # action is start
    sleep 1 # give process time to die for configuration file errors ...
    until pgrep -q $process || [[ $count -gt 5 ]]
    do
      echo "waiting for $process to start $count ..."
      sleep 1
      ((count++))
    done
    if pgrep -q $process; then
      echo "$process started OK ..."
    else
      error_exit "$process failed to start"
    fi
  fi
}

php_start()
{
  if pgrep -q php-fpm; then
    echo "${linked} is already started."
    exit 0
  fi
 
  if [[ ${linked} != ${version} ]]; then
    brew unlink ${linked} > /dev/null 2>&1 &&
      brew link --force --overwrite ${version} > /dev/null 2>&1
    linked=${version}
    plfile=$(find /opt/homebrew/opt/${linked}/homebrew.mxcl.php*.plist)
    if [[ $? -ne 0 ]]; then
      error_exit "Did not find linked ${linked} plist file ..."
    fi
  fi

  cp ${plfile} ~/Library/LaunchAgents/
  if [[ $? -ne 0 ]]; then
    error_exit "${plfile} copy failed ..."
  fi
  
  launchctl load ~/Library/LaunchAgents/$(basename ${plfile}) > /dev/null 2>&1
  pwait php-fpm start
}

php_stop()
{
  launchctl unload ~/Library/LaunchAgents/$(basename ${plfile}) > /dev/null 2>&1
  pwait php-fpm stop

  # Don't start automatically
  rm -f ~/Library/LaunchAgents/$(basename ${plfile})
}

php_restart()
{

  # First, stop PHP ...
  if ! pgrep -q php-fpm; then
    echo "${linked} is already stopped, so just start it up ..."
    # Always restart the linked version
    if [[ ${version} != ${linked} ]]; then
      version=${linked}
    fi
    php_start
    exit 0
  fi

  launchctl unload ~/Library/LaunchAgents/$(basename ${plfile}) > /dev/null 2>&1
  pwait php-fom stop

  # Then, start PHP ...
  launchctl load ~/Library/LaunchAgents/$(basename ${plfile}) > /dev/null 2>&1
  pwait php-fpm start
}

php_status()
{
  ps -axo user,pid,start,etime,time,nice,vsz,rss,command |\
    egrep '^USER|php-fpm' | sed '/grep/d'
}

case $action in
  "start")
    php_start
    ;;
  "stop")
    php_stop
    ;;
  "restart")
    php_restart
    ;;
  "status")
    php_status
    ;;
  *)
    # Should never happen ...
    error_exit "Invalid action ..."
    ;;
esac

exit 0

Here are some examples of how to use it (first, be sure to run brew services stop php if you previously started PHP-FPM with the brew services start command):

# The currently linked version of PHP
$ php -v
PHP 8.3.6 (cli) (built: Apr 10 2024 14:21:20) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.3.6, Copyright (c) Zend Technologies
    with Zend OPcache v8.3.6, Copyright (c), by Zend Technologies

$ brew services stop php
Warning: Service `php` is not started.

$ phpctl
Usage:	phpctl start [8.2|8.3]
	phpctl restart
	phpctl stop
	phpctl status

$ phpctl status
USER               PID STARTED  ELAPSED      TIME NI      VSZ    RSS COMMAND

# Start the currently linked version of PHP-FPM
$ phpctl start
php-fpm started OK ...

# Get info about our running PHP-FPM processes
$ phpctl status
USER               PID STARTED  ELAPSED      TIME NI      VSZ    RSS COMMAND
george            9624  3:08PM    00:25   0:00.06  0 408981424  21968 php-fpm: master process (/opt/homebrew/etc/php/8.3/php-fpm.conf)
george            9626  3:08PM    00:25   0:00.00  0 408981664   2224 php-fpm: pool www
george            9627  3:08PM    00:25   0:00.00  0 408990880   2256 php-fpm: pool www

# Trying to start again ...
$ phpctl start
php@8.3 is already started.

# Stop the running version
$ phpctl stop
php-fpm stopped OK ...

phpctl status
USER               PID STARTED  ELAPSED      TIME NI      VSZ    RSS COMMAND

You can do the following to start a different (previous version in our case). It will also unlink the current version and link in (switch to) the new version:

$ php -v
PHP 8.3.6 (cli) (built: Apr 10 2024 14:21:20) (NTS)

$ phpctl start 8.2
php-fpm started OK ...

$ php -v
PHP 8.2.18 (cli) (built: Apr  9 2024 18:46:23) (NTS)

And you can click on http://localhost/info.php to verify that the new selected version is running.

Remember that you have to stop the current version before starting (switching to) a different version.

Setting up Virtual Hosts

At this point, Apache and PHP-FPM are working with our Mac localhost. The last item that I want to configure is a virtual hosts environment. I want to be able to create local test domains on my Mac. For example, I have created a local test domain called altoplace.tst:

$ mkdir -p ~/Sites/altoplace.tst

$ cd ~/Sites/altoplace.tst

$ vi index.html

$ cat index.html
<h1>Our Local Altoplace.tst Virtual Host</h1>

For each virtual host, I need a DNS entry, so that I am able to enter that name into Safari (or your favorite) browser on your Mac. I could add an entry for each virtual host to my /etc/hosts file, but instead of doing that, I will install a lightweight DNS server on my Mac that will resolve any domain name ending in tst (or test, or whatever local TLD you choose). Of course, don’t pick a real TLD. Also, I understand that the current version of Google Chrome forces all .dev domains to use SSL, so .dev might not be a good choice. I am going to install and use dnsmasq, using Homebrew. Also, I will show you my shell script, dnsctl, that I use to start and stop dnsmasq. Dnsmasq requires root privileges, and as I noted before, the brew services command does not play well with the sudo command.

Using a lightweight DHCP and caching DNS server, dnsmasq

Run the following commands to install and configure dnsmasq:

$ brew install dnsmasq

$ sudo mkdir -p /etc/resolver
Password:

$ cd /opt/homebrew/etc

$ mkdir -p ~/orig/opt/homebrew/etc

$ cp dnsmasq.conf ~/orig/opt/homebrew/etc

$ vi dnsmasq.conf

$ diff ~/orig/opt/homebrew/etc/dnsmasq.conf .
79a80
> address=/.tst/127.0.0.1

$ sudo bash -c 'echo "nameserver 127.0.0.1" > /etc/resolver/tst'

The last three lines creates the .tst domain. Actually, I made this a bit more complicated than needed. You could have just created a one line dnsmasq.conf file that contains address=/.tst/127.0.0.1. None of the other configuration options in this file are turned on for our simple application, but I wanted to keep the file as is in case I choose to make other configuration changes in the future.

At this point, I am ready to start the dnsmasq service. However, it needs to be started with root privileges, so I am going to again create my own shell script to start and stop dnsmasq. I called it dnsctl. Create an executable dnsctl in your bin directory and add the following:

#!/bin/zsh

# Usage: dnsctl start|stop|restart|status

error_exit()
{
  echo -e "$1" 1>&2
  exit 1
}

# Usage
if [[ ! $# -eq 1 || ! ($1 == "start" || $1 == "stop" || $1 == "restart" || $1 == "status") ]]
then
  error_exit "Usage: dnsctl start|stop|restart|status"
else
  action="$1"
fi

pwait()
{
  process=$1
  action=$2
  count=0

  if [[ $action == "stop" ]]
  then
    until ! pgrep -q $process || [[ $count -gt 5 ]]
    do
      echo "waiting for $process to stop $count ..."
      sleep 1
      ((count++))
    done
    if ! pgrep -q $process; then
      echo "$process stopped OK ..."
    else
      error_exit "$process failed to stop"
    fi
  else
    # action is start
    sleep 1 # give process time to die for configuration file errors ...
    until pgrep -q $process || [[ $count -gt 5 ]]
    do
      echo "waiting for $process to start $count ..."
      sleep 1
      ((count++))
    done
    if pgrep -q $process; then
      echo "$process started OK ..."
    else
      error_exit "$process failed to start"
    fi
  fi
}

dnsstart()
{
  if pgrep -q dnsmasq; then
    echo "dnsmasq is already started ..."
    exit 0
  fi
  
  if [ ! -f "/opt/homebrew/opt/dnsmasq/homebrew.mxcl.dnsmasq.plist" ]; then
    error_exit "Cannot start dnsmasq -- homebrew.mxcl.dnsmasq.plist is missing ..."
  fi
  
  sudo cp /opt/homebrew/opt/dnsmasq/homebrew.mxcl.dnsmasq.plist /Library/LaunchDaemons/
  if [ "$?" != "0" ]; then
    error_exit "homebrew.mxcl.dnsmasq.plist copy failed ..."
  fi
  
  sudo launchctl load /Library/LaunchDaemons/homebrew.mxcl.dnsmasq.plist > /dev/null 2>&1
  pwait dnsmasq start
}

dnsstop()
{
  if ! pgrep -q dnsmasq; then
    echo "dnsmasq is already stopped ..."
    exit 0
  fi
 
  sudo launchctl unload /Library/LaunchDaemons/homebrew.mxcl.dnsmasq.plist > /dev/null 2>&1
  pwait dnsmasq stop

  # Don't start automatically
  sudo rm -f /Library/LaunchDaemons/homebrew.mxcl.dnsmasq.plist
}

dnsrestart()
{
  # First, stop dnsmasq ...
  if ! pgrep -q dnsmasq; then
    echo "dnsmasq is already stopped, so just start it up ..."
    dnsstart
    exit 0
  fi

  sudo launchctl unload /Library/LaunchDaemons/homebrew.mxcl.dnsmasq.plist > /dev/null 2>&1
  pwait dnsmasq stop

  # Then, start dnsmasq ...
  sudo launchctl load /Library/LaunchDaemons/homebrew.mxcl.dnsmasq.plist > /dev/null 2>&1
  pwait dnsmasq start
}

dnsstatus()
{
  # Get info about running dnsmasq processes
  ps -axo user,pid,start,etime,time,nice,vsz,rss,command |\
    egrep '^USER|dnsmasq' | sed '/grep/d'
}

case $action in
  "start")
    dnsstart
    ;;
  "stop")
    dnsstop
    ;;
  "restart")
    dnsrestart
    ;;
  "status")
    dnsstatus
    ;;
  *)
    # Should never happen ...
    error_exit "Invalid action ..."
    ;;
esac

exit 0

The following shows how to start up dnsmasq with the dnsctl shell script:

$ dnsctl
Usage: dnsctl start|stop|restart|status

$ dnsctl start
Password:
dnsmasq started OK ...

$ dnsctl status
USER               PID STARTED  ELAPSED      TIME NI      VSZ    RSS COMMAND
nobody            5384 10:50AM    00:10   0:00.02  0 408785104   2368 /opt/homebrew/opt/dnsmasq/sbin/dnsmasq --keep-in-foreground -C /opt/homebrew/etc/dnsmasq.conf -7 /opt/homebrew/etc/dnsmasq.d,*.conf

$ ping -c1 anydoman.tst
PING anydoman.tst (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.025 ms

--- anydoman.tst ping statistics ---
1 packets transmitted, 1 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 0.025/0.025/0.025/0.000 ms

Try it out! You can ping any domain that you can dream of (if it ends in .tst). The dnsctl script doesn’t change file permissions like the sudo brew services … command will do (when using sudo). Also, dnsutl tries to verify that the service successfully started (or stopped). I have seen cases where the brew services command said that the service started, but it actually didn’t (or died right away).

Enhanced Dnsmasq Configuration

All of the above is still good. However, I learned that there are certain applications, such as the WordPress Site Health check, that do not work with the resolver that we set up at /etc/resolver/tst. The WordPress Site Health check will report critical errors that are failing with this error:

Error: cURL error 6: Could not resolve: yourdomain.tst (Domain name not found) (http_request_failed)

I learned that this error is coming from the brew-installed curl-openssl program. You may not have WordPress installed. Another way to reproduce this issue is with the built-in host command:

$ host altoplace.tst         
Host altoplace.tst not found: 3(NXDOMAIN)

$ ping -c1 altoplace.tst
PING altoplace.tst (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.021 ms

--- altoplace.tst ping statistics ---
1 packets transmitted, 1 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 0.021/0.021/0.021/0.000 ms

The host command fails, but the ping command still works. There is a simple solution. I need to update the MacOS Network DNS setting to add the dnsmasq server IP, which is 127.0.0.1. This can be done through MacOS System Preferences Network panel. However, I can use the command line to easily change the MacOS DNS setting:

networksetup -setdnsservers Ethernet 127.0.0.1 192.168.1.1
networksetup -setdnsservers Wi-Fi 127.0.0.1 192.168.1.1

# Verify DNS server update:
$ networksetup -getdnsservers Ethernet
127.0.0.1
192.168.1.1

$ networksetup -getdnsservers Wi-Fi
127.0.0.1
192.168.1.1
On my Mac, these updated DNS settings are changed back to the original after restarting my Mac. Most likely it’s being over written by a DHCP update. I haven’t investigated how to make these changes “permanent.” For now, I just rerun the networksetup command.

A couple of comments. The 127.0.0.1 address must be first. The second address(es) should be a list of your current DNS addresses. You can look at the Network Panel before executing the above commands to determine what those addresses are (or use the -getdnsservers option). If the address is grayed-out, as it was in my case, it means that the DNS address was automatically setup by the DHCP service. You only need to execute the above command for the network interfaces that you are using. In my case, I have setup both Ethernet and Wi-Fi (defaults to using Ethernet).

Now, you can try the same host command and see that the issue is resolved:

$ host altoplace.tst                                        
altoplace.tst has address 127.0.0.1

Also, with this updated configuration, DNS queries are now being cached by dnsmasq:

$ dig +noall +stats altoplace.net
;; Query time: 69 msec
;; SERVER: 127.0.0.1#53(127.0.0.1)
;; WHEN: Mon Mar 04 15:51:29 CST 2024
;; MSG SIZE  rcvd: 58

$ dig +noall +stats altoplace.net
;; Query time: 0 msec
;; SERVER: 127.0.0.1#53(127.0.0.1)
;; WHEN: Mon Mar 04 15:51:33 CST 2024
;; MSG SIZE  rcvd: 58
The second Query time is 0 msec. It’s not a big savings, but it might add up over time. Anyway, the critical WordPress Site Health check issues are resolved. And a final note, if you do make this network DNS update, the /etc/resolver/tst file is no longer needed and can be safely removed.

Creating The First Virtual Host

I am going to update the Apache configuration to support SSL-enabled virtual hosts. I first need to make some additional changes to the Apache configuration files (httpd.conf and extra/httpd-ssl.conf):

$ cd /opt/homebrew/etc/httpd

$ vi httpd.conf

$ diff ~/orig/opt/homebrew/etc/httpd/httpd.conf .

 o o o
92c92
< #LoadModule socache_shmcb_module lib/httpd/modules/mod_socache_shmcb.so
---
> LoadModule socache_shmcb_module lib/httpd/modules/mod_socache_shmcb.so
150c150
< #LoadModule ssl_module lib/httpd/modules/mod_ssl.so
---
> LoadModule ssl_module lib/httpd/modules/mod_ssl.so
174c174
< #LoadModule vhost_alias_module lib/httpd/modules/mod_vhost_alias.so
---
> LoadModule vhost_alias_module lib/httpd/modules/mod_vhost_alias.so
181c181
< #LoadModule rewrite_module lib/httpd/modules/mod_rewrite.so
---
> LoadModule rewrite_module lib/httpd/modules/mod_rewrite.so

o o o
506c514
< #Include /opt/homebrew/etc/httpd/extra/httpd-vhosts.conf
---
> Include /opt/homebrew/etc/httpd/extra/httpd-vhosts.conf
523c531
< #Include /opt/homebrew/etc/httpd/extra/httpd-ssl.conf
---
> Include /opt/homebrew/etc/httpd/extra/httpd-ssl.conf

# Update extra/httpd-ssl.conf ...

$ mkdir -p ~/orig/opt/homebrew/etc/httpd/extra
$ cd /opt/homebrew/etc/httpd/extra
$ cp httpd-ssl.conf ~/orig/opt/homebrew/etc/httpd/extra

$ vi httpd-ssl.conf

$ diff ~/orig/opt/homebrew/etc/httpd/extra/httpd-ssl.conf .
36c36
< Listen 8443
---
> Listen 443
121c121
< <VirtualHost _default_:8443>
---
> <VirtualHost _default_:443>
124,125c124,125
< DocumentRoot "/opt/homebrew/var/www"
< ServerName www.example.com:8443
---
> DocumentRoot "/Users/george/Sites"
> ServerName localhost:443
144c144
< SSLCertificateFile "/opt/homebrew/etc/httpd/server.crt"
---
> SSLCertificateFile "/opt/homebrew/etc/httpd/ssl/localhost+2.pem"
154c154
< SSLCertificateKeyFile "/opt/homebrew/etc/httpd/server.key"
---
> SSLCertificateKeyFile "/opt/homebrew/etc/httpd/ssl/localhost+2-key.pem"

I uncommented two LoadModule lines to enable SSL. I also uncommented and updated the httpd-ssl.conf include file (don’t try to restart Apache yet). I changed the Homebrew default SSL port number (8443) back to the standard SSL port number (443). I still need to create the SSL certificate and key files before restarting Apache.

Creating the Virtual Host SSL Configuration

I need to add the Virtual Hosts configuration for SSL-enabled virtual hosts to the Apache configuration:

$ cd /opt/homebrew/etc/httpd/extra
$ cp httpd-vhosts.conf ~/orig/opt/homebrew/etc/httpd/extra

$ vi httpd-vhosts.conf

# Delete the existing example virtual hosts and add the following ...

<VirtualHost *:443>
    DocumentRoot "/Users/george/Sites"
    ServerName localhost
    SSLEngine on
    SSLCertificateFile "/opt/homebrew/etc/httpd/ssl/localhost+2.pem"
    SSLCertificateKeyFile "/opt/homebrew/etc/httpd/ssl/localhost+2-key.pem"
</VirtualHost>

<VirtualHost *:443>
    DocumentRoot "/Users/george/Sites"
    ServerName mac1.local
    SSLEngine on
    SSLCertificateFile "/opt/homebrew/etc/httpd/ssl/mac1.local.pem"
    SSLCertificateKeyFile "/opt/homebrew/etc/httpd/ssl/mac1.local-key.pem"
</VirtualHost>

<VirtualHost *:443>
    DocumentRoot "/Users/george/Sites/altoplace.tst"
    ServerName altoplace.tst
    SSLEngine on
    SSLCertificateFile "/opt/homebrew/etc/httpd/ssl/altoplace.tst.pem"
    SSLCertificateKeyFile "/opt/homebrew/etc/httpd/ssl/altoplace.tst-key.pem"
</VirtualHost>

Again, I am not ready to restart Apache. I will now create the local SSL certificate and key files referenced in the httpd-vhosts.conf and extra/httpd-ssl.conf files.

Creating the local SSL certificates and keys

I could use OpenSSL to create a self-signed certificate. But instead, I am going to use mkcert to create the SSL certificates. It is easy to use to create locally trusted development certificates. I will use Homebrew to install mkcert. Here’s how to install and use mkcert:

You will have to enter your system password to install a local Certificate Authority (CA) in the system trust store.
$ brew install mkcert nss

$ mkcert -install  
Created a new local CA 💥
The local CA is now installed in the system trust store! ⚡️

$ cd /opt/homebrew/etc/httpd

$ mkdir ssl

$ cd ssl

$ mkcert localhost 127.0.0.1 ::1

Created a new certificate valid for the following names 📜
 - "localhost"
 - "127.0.0.1"
 - "::1"

The certificate is at "./localhost+2.pem" and the key at "./localhost+2-key.pem"
It will expire on 12 July 2026 🗓

$ mkcert altoplace.tst

Created a new certificate valid for the following names 📜
 - "altoplace.tst"

The certificate is at "./altoplace.tst.pem" and the key at "./altoplace.tst-key.pem"
It will expire on 12 July 2026 🗓

$ mkcert mac1.local

Created a new certificate valid for the following names 📜
 - "mac1.local"

The certificate is at "./mac1.local.pem" and the key at "./mac1.local-key.pem"
It will expire on 12 July 2026 🗓

The nss package provides libraries that Firefox needs for using SSL. I ran mkcert -install one time to install a locally trusted certificate in the system trust store. Then I created a certificate for localhost. The last two examples shows how easy it is to create additional certificates for locally hosted domains. Of course, you will use your own local development domains. At this point, you are ready to restart Apache and test your SSL-enabled domains. I would suggest running the Apache configuration test first just to verify that you didn’t make any configuration errors. I certainly did find errors the first time around:

$ apachectl configtest                                  
Syntax OK

$ a2ctl restart
Password:
httpd stopped OK ...
httpd started OK ...

$ a2ctl status
USER               PID STARTED  ELAPSED      TIME NI      VSZ    RSS COMMAND
root              6365 11:52AM    00:05   0:00.05  0 408673696  10192 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND
george            6367 11:52AM    00:05   0:00.00  0 408702560   3952 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND
george            6368 11:52AM    00:05   0:00.00  0 408710752   3936 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND
george            6369 11:52AM    00:05   0:00.00  0 408710752   4000 /opt/homebrew/opt/httpd/bin/httpd -D FOREGROUND

# Now test your SSL URLs:

# https://localhost
# https://mac1.local
# https://altoplace.tst

I ran the Apache configuration test, restarted Apache, and for good measure, I verified that the httpd processes were running (and had just started). If you encounter any errors, be sure to check your Apache error log (/opt/homebrew/var/log/httpd/error.log).

I can now access my SSL-enabled altoplace.tst virtual host. You should see the test message, Our Local Altoplace.tst Virtual Host, and note the padlock indicating that SSL is enabled. You can also add an info.php file, as we did before, and verify that PHP is working for the virtual host, altoplace.tst/info.php .

Final Thoughts

At this point, you should have a basic Apache/PHP-FPM working environment, supporting SSL-enabled virtual hosts. You can start testing website environments, such as Grav or Hugo , that don’t require a MySQL database. I wrote a different post about setting up MySQL to complete the local development LAMP stack (but for the Mac instead of Linux) environment. Then I wrote about how to installed WordPress using WP-CLI .