January 21, 2018

PHP Apps in a Subdirectory in Nginx

We see how to properly configure Nginx to nest a PHP application in a sub-directory.

In this video, we work through how to put your PHP application in a subdirectory of another site.

For example, we may have an application running at example.org but need a second application running at example.org/blog.

This feels like it should be simple, but it turns out to be more complex and fraught with confusing Nginx configurations! To make matter worse (or, perhaps, to illustrate this point), a quick Google search reveals a TON of confusing, non-working examples.

What We're Using

The server is Ubuntu 16.04, , we install Nginx 1.13 and PHP 7.2. The example PHP applications are Laravel 5.5.

TL;DR

Here's the working configuration to have two Laravel apps working, where one application exists in a subdirectory of another.

server {
    listen 80 default_server;
    listen [::]:80 default_server;

    root /var/www/top/public;

    index index.html index.htm index.php;

    server_name _;

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location /nested {
        alias /var/www/nested/public;
        try_files $uri $uri/ @nested;

        location ~ \.php$ {
            include snippets/fastcgi-php.conf;
            fastcgi_param SCRIPT_FILENAME $request_filename;
            fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
        }
    }

    location @nested {
        rewrite /nested/(.*)$ /nested/index.php?/$1 last;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
    }
}

How This Works

Let's cover some details of the above configuration to see what's going on.

Project Locations

In this example, the application files of the two applications don't actually exist within one another. The application top exists in /var/www/top while the "nested" application lives in /var/www/nested.

This is, in my opinion, the prefered setup in this case, as nesting application files where you "physically" put one project's files inside of another's gets really messy with most PHP applications' use of a public directory as a web root.

Your case may change a bit depending on the applications of course, but that shouldn't change too much of this guide.

The Basic Setup

We start with a basic server configuration, good for serving a single application:

server {
    listen 80 default_server;

    root /var/www/top/public;

    index index.html index.htm index.php;

    server_name _;

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
    }
}

This excludes some stuff you might want in a production app (e.g. cache headers for static assets), but is enough to get a PHP application working fine.

The relevant parts to this guide are:

  • root - we define the web root that is used with application top
  • index - We define index.php as one of the default files to look for if no file is specified in the URI
  • location { try_files ... } - We use the standard try_files directive in a block that catches all URI's. This looks to see if a file or directory sent in the URI exists within the root directory. If it does not exist, it goes to the index.php file, which we know for sure is in the root directory.

Any part of the URI is appended to the root directive. So a URI of /foo/bar will search for a file or directory /var/www/top/public/foo/bar. In combination with the index directive, it also seaches for /var/www/top/public/foo/bar/index.[html|htm|php].

More interestingly however, the try_files directive will send most requests (except those for static assets - js, css files) to /index.php. Just like with any other URI, that is getting appended to the root, so Nginx will look for /var/www/top/index.php.

The last location ~ \.php$ {...} block is used for any files ending in .php. It sends the file off to PHP-FPM for processing.

Adding an App in a Subdirectory

We run into a few issues when running a second app within a subdirectory. Let's walk through them.

First, we know we want a second application to run in subdirectory /nested (the subdirectory is arbitrary, I just chose to name it "nested"). So, we create a location block for the "nested" location:

location /nested {

}

Now we can add to it to get our application running.

web root:

We know we have a separate web root for the nested project. Like me, your first inclination may be to set a root to point Nginx to /var/www/nested/public, but this doesn't end up working! Instead, we need to use an alias:

location /nested {
    # We point the alias to the "nested" 
    # project's web root
    alias /var/www/nested/public;
}

An alias is similar to root, but it has different behavior.

Nginx combines the root + the given URI to find a file on the disk drive. For example, a URI /foo/bar will instruct Nginx to serve /var/www/top/public/foo/bar. Since that directory doesn't exist, the try_files part tells Nginx to send the request to /var/www/top/public/index.php.

The included snippets/fastcgi-php.conf configuration takes care of parsing out /foo/bar as the intended URI that the PHP application uses as a route.

The alias does not behave exactly like this. In our example, the /nested URI is aliased to /var/www/nested/public. A URI of /nested will therefore look in /var/www/nested/public for a file to serve. A URI of /nested/foo will instruct Nginx to look in /var/www/nested/public/foo; the /nested portion is omitted.

Note that distinction - with that alias, Nginx does NOT look for files within /var/www/nested/public/nested/foo, like it would with the root directive.

try_files:

So, alias has the behavior we want - it won't try to look for files inside of some nested directory that does not exist. We still need to make a URI like /nested/foo serve from file /var/www/nested/public/index.php, so we have to add another try_files.

location /nested {
    alias /var/www/nested/public;
    try_files $uri $uri/ /index.php$is_args$args;
}

That looks very familiar! However, we're going to have to do some more PHP things specifically in the /nested location, so let's get a PHP handler in there also:

location /nested {
    alias /var/www/nested/public;
    try_files $uri $uri/ /index.php$is_args$args;

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_param SCRIPT_FILENAME $request_filename;
        fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
    }
}

The PHP handler location block is nested within the location /nested {...} block so we can customize it a bit.

Note that I snuck something into the PHP location block there. I (re)set the SCRIPT_FILENAME FastCGI parameter to $request_filename. This normally is $document_root$fastcgi_script_name, which in our configuration would always point to /var/www/top/public/index.php - the wrong application!

Instead we use $request_filename, which has the following definition in the Nginx docs:

file path for the current request, based on the root or alias directives, and the request URI

So this takes the alias into account correctly, and will pass the correct path to the index.php file in our nested application.

It works?!

This will appear to work at first! Heading to URI /nested will work fine. What happens is /nested is aliased to /var/www/nested/public and try_files sends that to /index.php, which ends up correctly being /var/www/nested/public/index.php.

However, if our app has a defined route (foo, for example), and we try to head to URI /nested/foo to reach it, we receive a 404 error! Not only that, it's a 404 page from the top level app!

This request ends up being handled by the outer PHP handler, which uses $document_root$fastcgi_script_name for the SCRIPT_FILENAME, thus pointing to file /var/www/top/public/index.php and being served by the top level app.

I've confirmed and tested the request is handled by the location /nested block, and is then sent to the wrong location ~ \.php$ {...} block. I'm not sure why this happens though! If you do, drop me a tweet @fideloper or email - chris AT this site's domain.

To get around this issue, we rewrite the URI to put index.php in the correct spot in the eyes of Nginx.

rewrite:

To implement the rewrite, we'll use a named location:

location /nested {
    alias /var/www/nested/public;
    try_files $uri $uri/ @nested;

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_param SCRIPT_FILENAME $request_filename;
        fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
    }
}

location @nested {
    rewrite /nested/(.*)$ /nested/index.php?/$1 last;
}

We adjust try_files to send requests to @nested, and then define @nested as a named location block.

This location block rewrites the URI so /nested/foo becomes /nested/index.php/foo. The last directive tells Nginx to use the new, rewritten URI and match it against the other location blocks.

Nginx then re-reaches the location /nested {...} block, where it's parsed correctly! The index.php file is in the correct location on the disk, and the included fastcgi-php.conf configurations parse out the foo route from the rest of the URI as normal (via fastcgi_split_path_info, if you were curious).

Don't forget the needed change to the SCRIPT_FILENAME FastCGI parameter explained above as well. That sends our correctly-parsed script file path to PHP-FPM.

Testing

Here's what I did to create this scenario and test out the Nginx configuration:

# Ubuntu 16.04 Server

sudo add-apt-repository -y ppa:ondrej/php
sudo add-apt-repository -y ppa:nginx/development
sudo apt-get update

sudo apt-get install -y nginx php7.2-fpm php7.2-cli \
    php7.2-pgsql php7.2-sqlite3 php7.2-gd \
    php7.2-curl php7.2-memcached \
    php7.2-imap php7.2-mysql php7.2-mbstring \
    php7.2-xml php7.2-zip php7.2-bcmath php7.2-soap \
    php7.2-intl php7.2-readline php7.2-imagick php-msgpack php-igbinary

php -r "readfile('http://getcomposer.org/installer');" | sudo php -- --install-dir=/usr/bin/ --filename=composer

cd /var/www
sudo composer create-project laravel/laravel top
sudo composer create-project laravel/laravel nested

sudo chown -R www-data: top nested

All Topics