Nginx Unit with Laravel and PHP

Nginx Unit is a "Universal Web App Server" brought to you by Nginx. This is a web server that can "directly" communicate with your code base, helping you pass off HTTP requests to your code in a way it can understand.

It supports a bunch of languages and has a module for each supported language. That lets it treat each language specially, as needed.

For PHP support, it has a PHP module that creates PHP processes, similar(ish) to PHP-FPM, but without needing PHP-FPM. Getting rid of PHP-FPM sounds really rad to me, so I decided to see how it works.

Install PHP

We'll install PHP the usual way on any Ubuntu server - using the ppa:ondrej/php repository. This is basically the de-facto way to use PHP on Ubuntu servers. It lets us get the latest PHP and install multiple versions of PHP on the same server (if so desired).

One issue with Unit: It expects the system-set default PHP version to be used. Luckily, we can recompile it's PHP module ourself (see here) to use our ppa:ondrej/php-installed PHP.

First, we'll just install PHP as usual.

sudo add-apt-repository -y ppa:ondrej/php
sudo apt-get install -y php8.2-dev php8.2-embed \
                   php8.2-bcmath php8.2-cli php8.2-common php8.2-curl \
                   php8.2-gd php8.2-intl php8.2-mbstring php8.2-mysql php8.2-pgsql \
                   php8.2-redis php8.2-soap php8.2-sqlite3 php8.2-xml php8.2-zip
curl -sLS https://getcomposer.org/installer | sudo php -- --install-dir=/usr/bin/ --filename=composer

Three things to note:

  1. We need php8.2-dev (the -dev version) of PHP to get certain PHP "header" files, allowing us to later compile Unit's PHP module
  2. We need php8.2-embed, as this is the SAPI that unit uses to spin up PHP processes.
  3. We do NOT need php-fpm, and so we don't install it

Everything else is all the usual "stuff" for a PHP installation, typically seen on a Forge server.

Install Nginx Unit

We can install Nginx Unit as per their docs - nothing special to do there!

I used Ubuntu 22.04, and followed their instructions for that system:

sudo curl --output /usr/share/keyrings/nginx-keyring.gpg  \
      https://unit.nginx.org/keys/nginx-keyring.gpg

echo "deb [signed-by=/usr/share/keyrings/nginx-keyring.gpg] https://packages.nginx.org/unit/ubuntu/ jammy unit
deb-src [signed-by=/usr/share/keyrings/nginx-keyring.gpg] https://packages.nginx.org/unit/ubuntu/ jammy unit
" | sudo tee /etc/apt/sources.list.d/unit.list

sudo apt-get update
sudo apt-get install -y unit

I only installed unit, opting NOT to install unit-dev nor unit-php yet, as we'll need to compile the PHP module ourself.

Manually Build Unit's PHP Module

Next we manually (so uncultured) re-build Unit's PHP module to work with ppa:ondrej/php. This was taken from the aforementioned GitHub issue which helpfully pointed out how we can make this work.

Run the following as user root:

cd /opt

# Latest version of Unit as of this release
VERSION="1.31.0"
curl -O https://unit.nginx.org/download/unit-$VERSION.tar.gz
tar xzf unit-$VERSION.tar.gz
cd unit-$VERSION

./configure --prefix=/usr --state=/var/lib/unit --control=unix:/var/run/control.unit.sock \
    --pid=/var/run/unit.pid --log=/var/log/unit.log --tmp=/var/tmp --user=unit --group=unit \
    --tests --openssl --modules=/usr/lib/unit/modules --libdir=/usr/lib/x86_64-linux-gnu
./configure php --module=php82 --config=php-config8.2
make php82
make install php82-install

# Restart 
systemctl unit restart

# Check logs to ensure PHP module is loaded
cat /var/log/unit.log

We compiled the PHP module ourself, which uses the currently-installed PHP version (taken from the ppa:ondrej/php repository). Success!

Create a Laravel App

In our case, we'll just directly create a new Laravel application on our server:

mkdir -p /var/www
cd /var/www
composer create-project laravel/laravel html

# Ensure files are owned by "unit", the user created
# by unit, so PHP can write to log files, etc
chown -R unit:unit /var/www/html

Easy enough!

Configure Unit

We can now configure Unit to run our application.

Create /var/www/unit.json with:

{
    "listeners": {
        "*:80": {
            "pass": "routes"
        }
    },

    "routes": [
        {
            "match": {
                "uri": "!/index.php"
            },
            "action": {
                "share": "/var/www/html/public$uri",
                "fallback": {
                    "pass": "applications/laravel"
                }
            }
        }
    ],

    "applications": {
        "laravel": {
            "type": "php",
            "root": "/var/www/html/public/",
            "script": "index.php",
            "processes": {}
        }
    }
}

See the video for a full explanation of this configuration.

Then tell Unit to use this configuration to run the application:

sudo curl -X PUT --data-binary @unit.json --unix-socket \
       /var/run/control.unit.sock http://localhost/config/

Head to your application (listening on port 80 currently)! Test it out with curl localhost.

Control API

You can use the Control API to retrieve and update configuration.

Let's GET our configuration:

curl -X GET \
    --unix-socket /var/run/control.unit.sock \
    http://localhost/config/

Pros of Unit

We can remove PHP-FPM! This is great, as it makes throwing our apps into a container a LOT simpler.

Unit also seems to be more efficient - I could NOT get it to break using ab to send 100 requests at a time, with 10,000 total requests.

Cons of Unit

There are some trade-offs!

First, we can't switch PHP versions without re-compiling the Unit PHP module. This means it will be hard (impossible?) to run multiple versions of PHP at the same time while using Unit.

You might also need another HTTP layer in front of Unit (Nginx, Cloudflare, Cloudfront, fly.io's HTTP layer, etc). It turns out that Unit either makes some standard-ish configuration hard, or can't do it at all. Some examples:

  1. Gzip compression isn't (yet?) a thing for Unit
  2. It makes protecting dot files or other routes difficult
  3. It's not easy to set cache headers for static assets