CakeDC Blog

TIPS, INSIGHTS AND THE LATEST FROM THE EXPERTS BEHIND CAKEPHP

Lighty Story

I will tell you a story. Once upon a time... Seriously though, it was not too long ago in the past - but it happened and it is possible you can benefit from it.

What?

This tutorial will show how to make lighttpd 1.4.20 serve virtual hosts with CakePHP applications. Our scenario is quite simple:

  1. For admin purposes, lighttpd will listen on localhost, it will serve several CakePHP applications on several external ip addresses, without SSL.
  2. Virtual hosts will be organized in groups and every group will use one CakePHP core checkout for its virtual hosts.
  3. Every virtual host will have it own access log (this server will not run hundreds of virtual hosts, so we can afford to waste one file descriptor for each) and its own directory for caching of compressed static files.
  4. Management of virtual hosts, their default and custom settings should be as easy as possible, so we can delegate the management of some ip addresses or just groups of virthosts to someone else and sleep well, because nobody will have to touch our precious configuration files.

However, our scenario has some special requirements which we need to solve. By the way, I will be showing you how to do things the hard way from the start. In hopes to spare you a lot of headaches in future. Lighttpd is sweet piece of software, and is under active development. Unfortunately, there are things that are not easy to set up. For example - when using any of provided virtual host modules, it is impossible to set up different access logs and cache directories for compressed content etc. dynamically in a pure lighty config file without external scripts. Everything (except for per virtual host errorlog) is possible by writing necessary configuration by hand. But we willing to work more now, so we can be lazy later!

There are several approaches for bash, Ruby etc. However, nothing usable in PHP as far as I know. I will show you how easy it could be. Take this as a working example, I am sharing ideas here, not bullet-proof all-mighty solutions. Lets go for it - and utilize PHP and the include_shell command in our lighttpd configuration file. The motto of this article is: it is easier read generated configuration, then write it by hand.

How? Lighty!

Don't think this is not a good answer. Lets set up a decent lighttpd installation. We'll assume you have it compiled and installed. Lets also assume that you have PHP prepared for lighttpd's ModFastCGI and are just waiting for configuration and the first test run. Also, for shell commands which need to be executed under root account, I'll use sudo in following examples.

    sudo mkdir /usr/local/etc/lighttpd

First of all, we need a directory for our custom configuration. When in doubt, a fast look into its contents will tell you everything one should know about virtual hosts configuration.

    sudo mkdir -p /usr/local/www/data/default/webroot
    echo "<html><head><title>It works<body>It works" > /usr/local/www/data/default/webroot/index.html

Next we created a directory for our default webroot. It will be used on localhost only, with index.html.

    sudo touch /var/log/lighttpd.error.log /var/log/lighttpd.access.log
    sudo chown www:www /var/log/lighttpd.error.log /var/log/lighttpd.access.log

Now we need to create error and access log files. The first one will be common for whole server, the second will be used for localhost only.

    sudo mkdir -p /var/cache/lighttpd/compress/default
    sudo chown -R www:www /var/cache/lighttpd

The last thing we had to prepare was the default directory for caching of compressed static files.

In /usr/local/etc/lighttpd.conf we will setup a simple config file containing the common configuration we will utilize later:

    server.modules = (
        "mod_simple_vhost",
        "mod_magnet",
        "mod_redirect",
        "mod_access",
        "mod_auth",
        "mod_expire",
        "mod_compress",
        "mod_fastcgi",
        "mod_accesslog"
    )
    
    server.document-root = "/usr/local/www/data/default/webroot/"
    server.errorlog = "/var/log/lighttpd.error.log"
    accesslog.filename = "/var/log/lighttpd.access.log"
    server.port = 80
    server.bind = "127.0.0.1"
    server.username = "www"
    server.groupname = "www"
    server.pid-file = "/var/run/lighttpd.pid"
    index-file.names = ( "index.php", "index.html", "index.htm", "default.htm" )
    
    # shortened !!!
    mimetype.assign = (
        ...
    )
    
    url.access-deny = ( "~", ".inc" )
    
    static-file.exclude-extensions = ( ".php", ".pl", ".fcgi" )
    
    dir-listing.activate = "disable"
    
    etag.use-mtime = "enable"
    static-file.etags = "enable"
    
    $HTTP["url"] =~ "^(/css/|/files/|/img/|/js/|/images/|/themed/|/favicon.ico)" {
        expire.url = ( "" => "access 7 days" )
    }
    
    compress.cache-dir = "/var/cache/lighttpd/compress/default/"
    compress.filetype = ( "text/plain", "text/html", "text/xml", "text/javascript", "text/css" )
    
    fastcgi.server = (
        ".php" => ((
            "bin-path" => "/usr/local/bin/php-cgi -c /usr/local/etc/php.ini",
            "socket" => "/tmp/lighttpd_php5.socket",
            "min-procs" => 1,
            "max-procs" => 1,
            "bin-environment" => (
                "FCGI_WEB_SERVER_ADDRS" => "127.0.0.1",
                "PHP_FCGI_CHILDREN" => "4",
                "PHP_FCGI_MAX_REQUESTS" => "1000"
            ),
            "bin-copy-environment" => ( "PATH", "SHELL", "USER"),
            "broken-scriptfilename" => "enable"
        ))
    )
    
    simple-vhost.server-root = "/usr/local/www/data/"
    simple-vhost.document-root = "webroot"
    simple-vhost.default-host = "default"
    
    $HTTP["host"] =~ "^www\.(.*)" {
        url.redirect = ( "^/(.*)" => "http://%1/$1" )
    }

How far along are we? So far we have a configured webserver with few preloaded modules and simple common configuration.

Our sever is currently:

  1. Listening on localhost:80.
  2. Refusing directory listing or sending some filetypes as plain text.
  3. Using etags and sending expiration headers for a set of static resources to 7 days by default. This allows us to schedule an upgrade of any virtual host just a week before it will happen.
  4. Using compression and caching of compressed static files for several mimetypes.
  5. Starting PHP as FastCGI, with only one parent process (we are going to use opcode cache). We are allowing only few child processes for this example tutorial and killing fcgi child processes after every 1000 requests
  6. Using mod_simple_vhost for name-based virtual hosting (preconfigured for fallback to default webroot).
  7. Redirecting all domains using www subdomain to the shorter version.

You will probably want to tweak some other settings. I am not going to describe all the server.max* configuration options, or talk about other pretty obvious things like mod_evasive, mod_status, mod_rrdtool etc, don't worry. Two things you should consider if some of your visitors will use one of the major browsers.

    $HTTP["url"] =~ "\.pdf$" {
        server.range-requests = "disable"
    }

You do not want to cut off IE users from your pdf documents, right?

    compress.filetype = ( "text/plain", "text/html", "text/xml" )
    $HTTP["useragent"] =~ "Firefox" {
        compress.filetype  += ("text/javascript", "text/css" )
    }

If your visitors are using an old (and/or above mentioned undesirable) internet browser, you can control compression settings per useragent in this way. Instead of the above example, compressing all 5 crucial mimetypes.

Ready to go? Ok, start lighttpd and make sure you see what you expect at http://localhost/

    echo "<?php phpinfo(); ?>" > /usr/local/www/data/default/webroot/phpinfo.php

Just to be sure that fcgi works as expected, try to see info about your current PHP setup at http://localhost/phpinfo.php and watch /var/log/lighttpd.error.log.

Url rewriting

It is possible to use lighttpd's mod_rewrite and create pattern for our static files if we are sure they exist. This approach has downsides though. We want to setup this part of webserver up and forget it exists. This is not possible with mod_rewrite, because for example, we are not going to force our developers to forget about /js/something.js as url for some of application controllers. Instead, we will use mod_magnet and custom Lua script. Visit this thread at CakePHP Google Group. Save the provided script to /usr/local/etc/lighttpd/cleanurl-v6.lua and add the following line to bottom of /usr/local/etc/lighttpd.conf:

    magnet.attract-physical-path-to = ( "/usr/local/etc/lighttpd/cleanurl-v6.lua" )

After restarting lighttpd, we are ready to remove all the .htaccess files from our filesystem and forget they exist. All requests for non-existing static files will be rewritten to /index.php?url=xxx like CakePHP requires.

Virtual hosts

Now we want to set up a directory structure and custom configuration for our virtual hosts and their groups. We will design a directory structure that can be used for dynamic configuration later, with no need to repeat anything obvious in configuration files. In this case, only logs folder matters (make sure it is writable by webserver). We will symlink everything else. Lets use the following directory structure with CakePHP core and our applications checkouts like our standard:

    # example.com (with redirect from www.example.com)
    /home/company/
                  logs/
                  www/
                      cake/
                      mainsite/
                               ...
                               webroot/
                      vendors/
    # dev-main.example.com and dev-product.example.com
    /home/development/
                  logs/
                  www/
                      cake/
                      mainsite/
                               ...
                               webroot/
                      product/
                               ...
                               webroot/
                      vendors/
    # stage-main.example.com and stage-product.example.com
    /home/staging/
                  logs/
                  www/
                      cake/
                      mainsite/
                               ...
                               webroot/
                      product/
                               ...
                               webroot/
                      vendors/
    # api.example.com, book.example.com, product.com ( with redirect from www.product.com)
    /home/product/
                  logs/
                  www/
                      api/
                          ...
                          index.html
                      book/
                               ...
                               webroot/
                      cake/
                      product/
                               ...
                               webroot/
                      vendors/

If you think the above directory tree is overcomplicated, or it seems too long for simple tutorial example, stop reading please, and feel free to come back any time later. It was nice to meet you :-) Things are only getting worse from here on in. For those brave enough to read on, you should have an idea of which domains will use which applications, and which applications will share one CakePHP core and folder for logs (not necessarily, read more).

Now we are getting somewhere - we need tell our webserver on which external ip addresses it has to listen for incoming connections, and which virtual hosts map to each ip address. Our www subdomains (redirected) should listen on a different ip address then their short versions. This allows us to use different SSL certificates for them later, if there is a need for secure connections. To show what is possible with our config parser, api.example.com will not use a /webroot/ folder, it contains just static html files. To make things even more tricky, api.example.com and book.example.com will not listen on same ip like their neighbour application product.com.

    cd /usr/local/etc/lighttpd

From now on, we will continue our work in this directory.

Lets say that we want to use ip 1.2.3.4 for domains example.com, api.example.com and book.example.com.

    sudo mkdir -p ./1.2.3.4:80/company
    sudo ln -s /home/company/www/cake ./1.2.3.4:80/company/cake
    sudo ln -s /home/company/www/vendors ./1.2.3.4:80/company/vendors
    
    sudo ln -s /home/company/www/mainsite ./1.2.3.4:80/company/example.com
    
    sudo mkdir ./1.2.3.4:80/product
    sudo ln -s /home/product/www/cake ./1.2.3.4:80/product/cake
    sudo ln -s /home/product/www/vendors ./1.2.3.4:80/product/vendors
    
    sudo ln -s /home/product/www/api ./1.2.3.4:80/product/api.example.com
    sudo ln -s /home/product/www/book ./1.2.3.4:80/product/book.example.com

What exactly did we just do? We created a folder named 1.2.3.4:80, containing 2 subfolders company and product. These will be used as groups of virtual hosts - their names should be the same as the name of their home directory (by default, path for logs can be adjusted). We will use them for setting paths to log files later. Both company and product have a symlinked cake and vendors folders and symlinks named as real domains and pointing to our app folders.

Lets continue - ip 2.3.4:5:80 will be used for rest of the group product.

    sudo mkdir -p ./2.3.4.5:80/product
    sudo ln -s /home/product/www/cake ./2.3.4.5:80/product/cake
    sudo ln -s /home/product/www/vendors ./2.3.4.5:80/product/vendors
    
    sudo ln -s /home/product/www/product ./2.3.4.5:80/product/product.com

That means only one virtual host for now.

Ok, ip 3.4.5.6 is going to be used for the www subdomains. No symlinks to existing applications are necessary here, because lighttpd will redirect requests coming to www.example.com to example.com automatically.

    sudo mkdir -p ./3.4.5.6:80/company/www.example.com ./3.4.5.6:80/product/www.product.com

We just had to create ip:port directory for the socket, group(s) of www virtualhosts and some domain-based directories just to have something to point default virtual host of this group at.

Staging and development checkouts will all share one ip 4.5.6.7.

    sudo mkdir -p ./4.5.6.7:80/development
    sudo ln -s /home/development/www/cake ./4.5.6.7:80/development/cake
    sudo ln -s /home/development/www/vendors ./4.5.6.7:80/development/vendors
    
    sudo ln -s /home/development/www/mainsite ./4.5.6.7:80/development/dev-main.example.com
    sudo ln -s /home/development/www/product ./4.5.6.7:80/development/dev-product.example.com
    
    sudo mkdir ./4.5.6.7:80/staging
    sudo ln -s /home/staging/www/cake ./4.5.6.7:80/staging/cake
    sudo ln -s /home/staging/www/vendors ./4.5.6.7:80/staging/vendors
    
    sudo ln -s /home/staging/www/mainsite ./4.5.6.7:80/staging/stage-main.example.com
    sudo ln -s /home/staging/www/product ./4.5.6.7:80/staging/stage-product.example.com

Four virtual hosts on one ip from different home folders (therefore placed in different groups).

The hard part is complete. Lets go through the bothering part of this custom setup. Did I said already that everything is a file? Don't be scared from amount of necessary steps, it will all be worth it in the future.

Lets look what we have done in directory /usr/local/etc/lighttpd/:

    1.2.3.4:80/
               company/
                        cake/        <-- /home/company/www/cake
                        example.com/ <-- /home/company/www/mainsite
                        vendors/     <-- /home/company/www/vendors
               product/
                        api.example.com/  <-- /home/product/www/api
                        book.example.com/ <-- /home/product/www/book
                        cake/             <-- /home/product/www/cake
                        vendors/          <-- /home/product/www/vendors
    2.3.4.5:80/
               product/
                        cake/        <-- /home/product/www/cake
                        product.com/ <-- /home/product/www/product
                        vendors/     <-- /home/product/www/vendors
    3.4.5.6:80/
               company/www.example.com/ <-- empty directory (redirected), necessary for default virtual host 
               product/www.product.com/ <-- empty directory (redirected), necessary for default virtual host
    4.5.6:7:80/
               development/
                        cake/                    <-- /home/development/www/cake
                        dev-main.example.com/    <-- /home/development/www/mainsite
                        dev-product.example.com/ <-- /home/development/www/product
                        vendors/                 <-- /home/development/www/vendors
               staging/
                        cake/                      <-- /home/staging/www/cake
                        stage-main.example.com/    <-- /home/staging/www/mainsite
                        stage-product.example.com/ <-- /home/staging/www/product
                        vendors/                   <-- /home/staging/www/vendors

Some new folders with symlinks.

Are you still with me? For those who know mod_simple_vhost, you should be already be pretty clear where we are going. Besides the accesslog path and compress folder path, we will also switch simple-vhost.server-root and simple-vhost.default-host in dependency of used socket and some hostname condition for virthost group. Actually, there is a bit more as well that I will show you.

The above directory structure shows that we have 7 groups of virtual hosts in 4 sockets, so lets create 7 simple configuration files for our groups of virtual hosts. Configuration file for group is not required in very special case - no regex pattern for this group, only one virtual host inside and - either only group in socket, or (alphabetically) last one.

<?php # /usr/local/etc/lighttpd/1.2.3.4:80/company/config.php
    $config['group'] = array(
        'host' => '^example\.com',
        'default' => 'example.com'
    );
?>
<?php # /usr/local/etc/lighttpd/1.2.3.4:80/product/config.php
    $config['group'] = array(
        'host' => '^(.*)\.example\.com',
        'default' => 'book.example.com'
    );
?>
<?php # /usr/local/etc/lighttpd/2.3.4.5:80/product/config.php
    $config['group'] = array(
        'host' => '^product\.com',
        'default' => 'product.com'
    );
?>
<?php # /usr/local/etc/lighttpd/3.4.5.6:80/company/config.php
    $config['group'] = array(
        'host' => '^(.*)\.example\.com',
        'default' => 'www.example.com'
    );
?>
<?php # /usr/local/etc/lighttpd/3.4.5.6:80/product/config.php
    $config['group'] = array(
        'host' => '^(.*)\.product\.com',
        'default' => 'www.product.com'
    );
?>
<?php # /usr/local/etc/lighttpd/4.5.6:7:80/development/config.php
    $config['group'] = array(
        'host' => '^dev-(.*)\.example\.com',
        'default' => 'dev-main.example.com'
    );
?>
<?php # /usr/local/etc/lighttpd/4.5.6:7:80/staging/config.php
    $config['group'] = array(
        'host' => '^stage-(.*)\.example\.com',
        'default' => 'stage-main.example.com'
    );
?>

And that's it. Every group (subfolder of ip.ad.dr.es:80 socket folder) has the required minimal configuration, and everything is properly set up. So lets see what we can take off from it.

Dynamic configuration

Extract this file in folder /usr/local/etc/lighttpd.

    sudo chmod a+x ./simple_config.php

Make simple_config.php executable for everyone.

Now run it as a non-privileged user.

    ./simple_config.php | more

You should see a basic generated configuration for your sockets, virthosts and virthosts groups.

Now we are already looking at a snippet of the generated configuration.

    #
    # Simple configuration parser output
    #
    # ERROR logfile /home/company/logs/example-access_log can not be created, SKIPPING
    # ERROR compress cache /var/cache/lighttpd/compress/example.com/ can not be created, SKIPPING
    # ERROR logfile /home/product/logs/api-access_log can not be created, SKIPPING
    # ERROR compress cache /var/cache/lighttpd/compress/api.example.com/ can not be created, SKIPPING
    # ERROR logfile /home/product/logs/book-access_log can not be created, SKIPPING
    # ERROR compress cache /var/cache/lighttpd/compress/book.example.com/ can not be created, SKIPPING
    # ERROR logfile /home/product/logs/product-access_log can not be created, SKIPPING
    # ERROR compress cache /var/cache/lighttpd/compress/product.com/ can not be created, SKIPPING
    # ERROR logfile /home/company/logs/www-access_log can not be created, SKIPPING
    # ERROR compress cache /var/cache/lighttpd/compress/www.example.com/ can not be created, SKIPPING
    # ERROR logfile /home/product/logs/www-access_log can not be created, SKIPPING
    # ERROR compress cache /var/cache/lighttpd/compress/www.product.com/ can not be created, SKIPPING
    # ERROR logfile /home/development/logs/dev-main-access_log can not be created, SKIPPING
    # ERROR compress cache /var/cache/lighttpd/compress/dev-main.example.com/ can not be created, SKIPPING
    # ERROR logfile /home/development/logs/dev-product-access_log can not be created, SKIPPING
    # ERROR compress cache /var/cache/lighttpd/compress/dev-product.example.com/ can not be created, SKIPPING
    # ERROR logfile /home/staging/logs/stage-main-access_log can not be created, SKIPPING
    # ERROR compress cache /var/cache/lighttpd/compress/stage-main.example.com/ can not be created, SKIPPING
    # ERROR logfile /home/staging/logs/stage-product-access_log can not be created, SKIPPING
    # ERROR compress cache /var/cache/lighttpd/compress/stage-product.example.com/ can not be created, SKIPPING
    #
    
    $SERVER["socket"] == "1.2.3.4:80" {
            $HTTP["host"] =~ "^example\.com" {
                    simple-vhost.server-root = "/usr/local/etc/lighttpd/1.2.3.4:80/company/"
                    simple-vhost.default-host = "example.com"
                    $HTTP["host"] == "example.com" {
                    ....

You can see which files this script is trying to create. It will create all of them when you will run it as root once. But there are two things we would like to fix first: access logs /home/company/logs/www-access_log and /home/product/logs/www-access_log are generated for our redirected domains.

Lets redirect these logs to those used by domains example.com and product.com:

<?php # /usr/local/etc/lighttpd/3.4.5.6:80/company/config.php
    $config['group'] = array(
        'host' => '^(.*)\.example\.com',
        'default' => 'www.example.com'
    );
    $config['virthosts'] = array(
        'www.example.com' => array(
            'log' => 'example'
        )
    );
?>
<?php # /usr/local/etc/lighttpd/3.4.5.6:80/product/config.php
    $config['group'] = array(
        'host' => '^(.*)\.product\.com',
        'default' => 'www.product.com'
    );
    $config['virthosts'] = array(
        'www.product.com' => array(
            'log' => 'product'
        )
    );
?>

Running ./simple_config.php as unprivileged user again shows this script is no longer trying to create any www-access_log files. We will not care about directories for compressed content, they can be used later, but we will never serve different content on example.com and www.example.com, so it is logical that they share one log file. Every decent logfile parser can handle several domains in one log file.

Now, you can run this script as root:

    sudo ./simple_config.php

and result will look much better now:

#
# Simple configuration parser output
#
# NOTICE created logfile /home/company/logs/example-access_log
# NOTICE created compress cache /var/cache/lighttpd/compress/example.com/
# NOTICE created logfile /home/product/logs/api-access_log
# NOTICE created compress cache /var/cache/lighttpd/compress/api.example.com/
# NOTICE created logfile /home/product/logs/book-access_log
# NOTICE created compress cache /var/cache/lighttpd/compress/book.example.com/
# NOTICE created logfile /home/product/logs/product-access_log
# NOTICE created compress cache /var/cache/lighttpd/compress/product.com/
# NOTICE created compress cache /var/cache/lighttpd/compress/www.example.com/
# NOTICE created compress cache /var/cache/lighttpd/compress/www.product.com/
# NOTICE created logfile /home/development/logs/dev-main-access_log
# NOTICE created compress cache /var/cache/lighttpd/compress/dev-main.example.com/
# NOTICE created logfile /home/development/logs/dev-product-access_log
# NOTICE created compress cache /var/cache/lighttpd/compress/dev-product.example.com/
# NOTICE created logfile /home/staging/logs/stage-main-access_log
# NOTICE created compress cache /var/cache/lighttpd/compress/stage-main.example.com/
# NOTICE created logfile /home/staging/logs/stage-product-access_log
# NOTICE created compress cache /var/cache/lighttpd/compress/stage-product.example.com/
#

    $SERVER["socket"] == "1.2.3.4:80" {
            $HTTP["host"] =~ "^example\.com" {
                    simple-vhost.server-root = "/usr/local/etc/lighttpd/1.2.3.4:80/company/"
                    simple-vhost.default-host = "example.com"
                    $HTTP["host"] == "example.com" {
                            accesslog.filename = "/home/company/logs/example-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/example.com/"
                    }
            }
            else $HTTP["host"] =~ "^(.*)\.example\.com" {
                    simple-vhost.server-root = "/usr/local/etc/lighttpd/1.2.3.4:80/product/"
                    simple-vhost.default-host = "book.example.com"
                    $HTTP["host"] == "api.example.com" {
                            accesslog.filename = "/home/product/logs/api-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/api.example.com/"
                    }
                    else $HTTP["host"] == "book.example.com" {
                            accesslog.filename = "/home/product/logs/book-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/book.example.com/"
                    }
            }
    }
    $SERVER["socket"] == "2.3.4.5:80" {
            $HTTP["host"] =~ "^product\.com" {
                    simple-vhost.server-root = "/usr/local/etc/lighttpd/2.3.4.5:80/product/"
                    simple-vhost.default-host = "product.com"
                    $HTTP["host"] == "product.com" {
                            accesslog.filename = "/home/product/logs/product-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/product.com/"
                    }
            }
    }
    $SERVER["socket"] == "3.4.5.6:80" {
            $HTTP["host"] =~ "^(.*)\.example\.com" {
                    simple-vhost.server-root = "/usr/local/etc/lighttpd/3.4.5.6:80/company/"
                    simple-vhost.default-host = "www.example.com"
                    $HTTP["host"] == "www.example.com" {
                            accesslog.filename = "/home/company/logs/example-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/www.example.com/"
                    }
            }
            else $HTTP["host"] =~ "^(.*)\.product\.com" {
                    simple-vhost.server-root = "/usr/local/etc/lighttpd/3.4.5.6:80/product/"
                    simple-vhost.default-host = "www.product.com"
                    $HTTP["host"] == "www.product.com" {
                            accesslog.filename = "/home/product/logs/product-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/www.product.com/"
                    }
            }
    }
    $SERVER["socket"] == "4.5.6.7:80" {
            $HTTP["host"] =~ "^dev-(.*)\.example\.com" {
                    simple-vhost.server-root = "/usr/local/etc/lighttpd/4.5.6.7:80/development/"
                    simple-vhost.default-host = "dev-main.example.com"
                    $HTTP["host"] == "dev-main.example.com" {
                            accesslog.filename = "/home/development/logs/dev-main-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/dev-main.example.com/"
                    }
                    else $HTTP["host"] == "dev-product.example.com" {
                            accesslog.filename = "/home/development/logs/dev-product-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/dev-product.example.com/"
                    }
            }
            else $HTTP["host"] =~ "^stage-(.*)\.example\.com" {
                    simple-vhost.server-root = "/usr/local/etc/lighttpd/4.5.6.7:80/staging/"
                    simple-vhost.default-host = "stage-main.example.com"
                    $HTTP["host"] == "stage-main.example.com" {
                            accesslog.filename = "/home/staging/logs/stage-main-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/stage-main.example.com/"
                    }
                    else $HTTP["host"] == "stage-product.example.com" {
                            accesslog.filename = "/home/staging/logs/stage-product-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/stage-product.example.com/"
                    }
            }
    }

Getting close to what we need from this setup.

I will process several steps now, and then I will paste here final output of config parser for you to compare with above one.

We have another domain manual.example.com (with no virthost set) and we want to redirect it to api.example.com with configuration only, it will be using its own manual-access_log. Furthermore, we want book.example.com condition happen sooner then the condition on api.example.com, because book is gaining more traffic, and attach domain aliases bibliotheca.example.com and bookstore.example.com to book.example.com. Also, expire headers for book should be set for 2 years and as previously mentioned api.example.com is not using /webroot/ folder.

<?php # /usr/local/etc/lighttpd/1.2.3.4:80/product/config.php
    $config['group'] = array(
        'host' => '^(.*)\.example\.com',
        'default' => 'book.example.com'
    );
    $config['virthosts'] = array(
        'book.example.com' => array(
            'expire' => array(
                '^(/css/|/files/|/img/|/js/|/images/|/themed/|/favicon.ico)' => 'access 2 years'
            ),
            'aliases' => array(
                'bibliotheca.example.com',
                'bookstore.example.com'
            )
        ),
        'api.example.com' => array(
            'webroot' => '/'
        ),
        'manual.example.com' => array(
             'redirect' => 'http://api.example.org/'
        )
    );
?>

All of it is fixed now. We even do not need folder/symlink for manual.example.com in this case.

Important note: we do not have to create folders for domains bibliotheca.example.com and bookstore.example.com, because they are aliases for book.example.com and it is used as default virtual host for this group! If you will set alias for non-default virtual host, you have to symlink aliased application several times to group folder - every time with a different domain name.

We want all staging sites to store logs in /home/development/logs. Also all staging and development sites should use expire headers for 5 minutes only and have to use http auth (one common file for now).

<?php # /usr/local/etc/lighttpd/4.5.6:7:80/development/config.php 
    $config['group'] = array(
        'host' => '^dev-(.*)\.example\.com', 
        'default' => 'dev-main.example.com', 
        'expire' => array(
             '^(/css/|/files/|/img/|/js/|/images/|/themed/|/favicon.ico)' => 'access 5 minutes' 
        ), 
        'auth' => array( 
            'backend' => 'htpasswd', 
            'file' => '/var/projects/company/.trac.htpasswd', 
            'protect' => array( 
                '/' => array( 
                    'realm' => 'Development Access', 
                    'require' => 'valid-user' 
                ) 
            )
        ) 
    );
?>
<?php # /usr/local/etc/lighttpd/4.5.6:7:80/staging/config.php 
    $config['group'] = array(
        'host' => '^stage-(.*)\.example\.com', 
        'default' => 'stage-main.example.com', 
        'expire' => array( 
            '^(/css/|/files/|/img/|/js/|/images/|/themed/|/favicon.ico)' => 'access 5 minutes' 
        ),
        'logs' => '/home/development/logs', 
        'auth' => array( 
            'backend' => 'htpasswd', 
            'file' => '/var/projects/company/.trac.htpasswd', 
            'protect' => array( 
                '/' => array( 
                    'realm' => 'Staging Access', 
                    'require' => 'valid-user' 
                ) 
            )
        ) 
    ); 
?>

This has all been fixed now.

Now our simple_config.php returns this:

    #
    # Simple configuration parser output
    #
    
    $SERVER["socket"] == "1.2.3.4:80" {
            $HTTP["host"] =~ "^example\.com" {
                    simple-vhost.server-root = "/usr/local/etc/lighttpd/1.2.3.4:80/company/"
                    simple-vhost.default-host = "example.com"
                    $HTTP["host"] == "example.com" {
                            accesslog.filename = "/home/company/logs/example-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/example.com/"
                    }
            }
            else $HTTP["host"] =~ "^(.*)\.example\.com" {
                    simple-vhost.server-root = "/usr/local/etc/lighttpd/1.2.3.4:80/product/"
                    simple-vhost.default-host = "book.example.com"
                    $HTTP["host"] =~ "^(book\.example\.com|bibliotheca\.example\.com|bookstore\.example\.com)" {
                            accesslog.filename = "/home/product/logs/book-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/book.example.com/"
                            $HTTP["url"] =~ "^(/css/|/files/|/img/|/js/|/images/|/themed/|/favicon.ico)" {
                                    expire.url = ("" => "access 2 years")
                            }
                    }
                    else $HTTP["host"] == "api.example.com" {
                            accesslog.filename = "/home/product/logs/api-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/api.example.com/"
                            simple-vhost.document-root = "/"
                    }
                    else $HTTP["host"] == "manual.example.com" {
                            accesslog.filename = "/home/product/logs/manual-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/manual.example.com/"
                            url.redirect = (
                                    ".*" => "http://api.example.org/"
                            )
                    }
            }
    }
    $SERVER["socket"] == "2.3.4.5:80" {
            $HTTP["host"] =~ "^product\.com" {
                    simple-vhost.server-root = "/usr/local/etc/lighttpd/2.3.4.5:80/product/"
                    simple-vhost.default-host = "product.com"
                    $HTTP["host"] == "product.com" {
                            accesslog.filename = "/home/product/logs/product-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/product.com/"
                    }
            }
    }
    $SERVER["socket"] == "3.4.5.6:80" {
            $HTTP["host"] =~ "^(.*)\.example\.com" {
                    simple-vhost.server-root = "/usr/local/etc/lighttpd/3.4.5.6:80/company/"
                    simple-vhost.default-host = "www.example.com"
                    $HTTP["host"] == "www.example.com" {
                            accesslog.filename = "/home/company/logs/example-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/www.example.com/"
                    }
            }
            else $HTTP["host"] =~ "^(.*)\.product\.com" {
                    simple-vhost.server-root = "/usr/local/etc/lighttpd/3.4.5.6:80/product/"
                    simple-vhost.default-host = "www.product.com"
                    $HTTP["host"] == "www.product.com" {
                            accesslog.filename = "/home/product/logs/product-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/www.product.com/"
                    }
            }
    }
    $SERVER["socket"] == "4.5.6.7:80" {
            $HTTP["host"] =~ "^dev-(.*)\.example\.com" {
                    simple-vhost.server-root = "/usr/local/etc/lighttpd/4.5.6.7:80/development/"
                    simple-vhost.default-host = "dev-main.example.com"
                    $HTTP["url"] =~ "^(/css/|/files/|/img/|/js/|/images/|/themed/|/favicon.ico)" {
                            expire.url = ("" => "access 5 minutes")
                    }
                    auth.backend = "htpasswd"
                    auth.backend.htpasswd.userfile = "/var/projects/company/.trac.htpasswd"
                    auth.require = (
                            "/" => (
                                    "method" => "basic",
                                    "realm" => "Development Access",
                                    "require" => "valid-user"
                            )
                    )
                    $HTTP["host"] == "dev-main.example.com" {
                            accesslog.filename = "/home/development/logs/dev-main-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/dev-main.example.com/"
                    }
                    else $HTTP["host"] == "dev-product.example.com" {
                            accesslog.filename = "/home/development/logs/dev-product-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/dev-product.example.com/"
                    }
            }
            else $HTTP["host"] =~ "^stage-(.*)\.example\.com" {
                    simple-vhost.server-root = "/usr/local/etc/lighttpd/4.5.6.7:80/staging/"
                    simple-vhost.default-host = "stage-main.example.com"
                    $HTTP["url"] =~ "^(/css/|/files/|/img/|/js/|/images/|/themed/|/favicon.ico)" {
                            expire.url = ("" => "access 5 minutes")
                    }
                    auth.backend = "htpasswd"
                    auth.backend.htpasswd.userfile = "/var/projects/company/.trac.htpasswd"
                    auth.require = (
                            "/" => (
                                    "method" => "basic",
                                    "realm" => "Staging Access",
                                    "require" => "valid-user"
                            )
                    )
                    $HTTP["host"] == "stage-main.example.com" {
                            accesslog.filename = "/home/development/logs/stage-main-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/stage-main.example.com/"
                    }
                    else $HTTP["host"] == "stage-product.example.com" {
                            accesslog.filename = "/home/development/logs/stage-product-access_log"
                            compress.cache-dir = "/var/cache/lighttpd/compress/stage-product.example.com/"
                    }
            }
    }

Now it looks like we are set with everything we needed.

One last line for /usr/local/etc/lighttpd.conf is:

    include_shell "/usr/local/etc/lighttpd/simple_config.php"

And that's all.

Before you will start or restart lighttpd, try and see if it can parse the new configuration (with our include) without errors, or inspect how it sees configuration after parsing:

    lighttpd -t -f /usr/local/etc/lighttpd.conf
    lighttpd -p -f /usr/local/etc/lighttpd.conf

It is better to run the above commands as root, off course.

Now what?

Think twice about patterns for groups - don't be surprised if you get 'It works' page or default virthost of another group, if you are too lazy to read the generated configuration! Groups are processed in alphabetical order - just so you know which patterns are going to be checked first. Well, it is possible to change order of groups - change name of some company group folder to xxx_company and:

    $config['group'] = array(
        'name' => 'company',

Now you should be fine - this group in folder named xxx_company instead of company, and everything will still work.

Everything that is necessary should be up and running now. Lighttpd should serve all virtual hosts from groups in sockets from now on. Read how to clear cache for mod_compress too. Smart brain should ask now, why we are using mod_simple_vhost, if our parser generates configuration for every virtual host it founds in our configuration files and directory structure. We don't do it, but you can - read code. Note for these who do not want or can not follow our default logs location, home directories, cache directories, user account lighttpd will use, or want to store directory structure with sockets/groups/virthosts somewhere else - read code too ;-) Reason why we set mod_simple_vhost for this example as default is simple - to get some domain serving some application, we need only one simple thing: symlink to app directory with domain name, placed in some virtual group in proper socket. This virtual host will be accessible immediately - although, restart of webserver is still necessary to have configuration for access logfile and compress directory for this virtual host (otherwise default accesslog and compress dir will be used), but not required.

A few questions remain, what and how needs to be done in obvious use cases - adding new ip addresses, groups, virthosts, or moving whole groups over sockets, moving virthosts over sockets, etc... I assume this part will be sweet piece of cake for you. Definitely - feel free to call simple_config.php as often as you want to. It is highly reccommended to save functional configuration to a backup file by redirecting the output. Sure, one can use include "/some/path/generated_output.conf" exclusively, instead of include_shell - it is up to you.

Backup, backup, backup. This is nothing more then a functional example, but the entire code lives in one class, so feel free to change or extend it for your needs. It is released under MIT license and is provided as it is, so you can do anything you want with it (except for removing license and copyright note). Keep in mind it was not tested in all possible situations and some of things I did not mention in this tutorial (but they are implemented in code) were not intensively tested yet.

If you feel that some of the subdomains used in this tutorial sound familiar to you, you are probably right. I didn't said it was going to be a fairy tale. I said, I will tell you a story. To be continued...

Latest articles

Closing Advent Calendar 2024

This article is part of the CakeDC Advent Calendar 2024 (December 24th 2024) That’s a wrap on the CakeDC 2024 advent calendar blog series. Did you get to read all of them? Hopefully you obtained some useful information to use in your future baking. We would love to get your feedback, feel free to share! It is still hard to believe that 2024 is almost over, but we are looking forward to an extraordinary 2025. On behalf of CakeDC, we want to thank our team for all the hours of hard work they put in this year. Also, thank you to our clients for trusting us with your CakePHP projects, it is an absolute pleasure getting to work with each of you. We are thankful for the great relationships we have built, or carried on in the last 12 months. For our CakePHP community, especially the core team, please know how incredibly grateful we are for your support of the framework. There is a reason that Cake is still around after 20 years, and it’s great developers like you, who dedicate their time and efforts to keep the code going. THANK YOU, THANK YOU, THANK YOU. As far as what is to come for CakePHP in 2025, stay tuned. However, I am told that there are some top secret (not really, we are opensource after all) talks about CakePHP 6 happening. With the release of PHP 8.4, I am sure some awesome features will be implemented in Cake specifically. We will also be celebrating 20 years of CakePHP next year, can you believe it? CakeFest will be in honor of all core members past and present, and it may be a good time to introduce some new ones as well. If you are a core member (or former), we would love to have you attend the conference this year. The location will be announced soon. Interested in getting involved or joining the core team? You can find some helpful links here: https://cakephp.org/get-involved We hope you enjoyed our gift this year, it’s the least we could do. Wishing you a happy holiday season from our CakeDC family to yours. See you next year! … sorry, I had to do it. :) Also, here are some final words from our President: Larry Masters.

A Christmas Message to the CakePHP Community

As we gather with loved ones to celebrate the joy and hope of the Christmas season, I want to take a moment to reflect on the incredible journey we’ve shared this year as part of the CakePHP community. This is a special time of year when people around the world come together to celebrate love, grace, and the hope that light brings into the world. It’s also a time to give thanks for the connections that make our lives richer. The CakePHP framework has always been about more than just code, it’s about people. It’s the collective effort of contributors from around the world who believe in building something better, together. To everyone who has shared their expertise, contributed code, written documentation, tested features, or offered guidance to others, I want to express my deepest gratitude for your dedication and passion. As we approach 2025, it brings even greater meaning to reflect on how far we’ve come. Next year marks the 20th anniversary of CakePHP. From the first lines of code to the projects we support today, the journey has been nothing short of remarkable. As we look ahead to the new year, let us carry forward this spirit of generosity, collaboration, and unity. Together, we can continue to empower developers, build exceptional tools, and foster a community that is inclusive, welcoming, and supportive. On behalf of everyone at Cake Development Corporation, I wish you and your families a blessed Christmas filled with peace, joy, and love. May the new year bring us more opportunities to create, connect, and grow together. Thank you for being part of this journey. Merry Christmas and a very Happy New Year to everyone. With gratitude, Larry Masters This article is part of the CakeDC Advent Calendar 2024 (December 24th 2024)

Railway Oriented Programming: A Functional Approach to Error Handling

This article is part of the CakeDC Advent Calendar 2024 (December 23rd 2024) Scott Wlaschin, a well known figure in the functional programming community, introduced the Railway Oriented Programming (ROP) pattern in his presentations and blog posts. His innovative approach to error handling has revolutionized how developers think about managing failures in their applications. Drawing inspiration from railway switches and tracks, Wlaschin created a metaphor that makes complex functional programming concepts more accessible to mainstream developers.

The Two-Track Model

At its core, Railway Oriented Programming visualizes data flow as a railway system with two parallel tracks: the success track and the failure track. This metaphor provides an elegant way to understand how data moves through an application while handling both successful operations and errors. Unlike traditional error handling with try-catch blocks or null checks, ROP treats success and failure as equal citizens, each flowing along its own track. This approach eliminates the need for nested error checking and creates a more linear, maintainable flow of operations.

Understanding Track Combinations

The railway model introduces several types of functions based on how they handle inputs and outputs. The simplest is the one-track function (1-1), which operates only on successful values, similar to a straight railway track. These functions take a value and return a value, without any concept of failure. Next, we have switch functions (1-2), which are like railway switches that can direct our train (data) onto either the success or failure track. Finally, two-track functions (2-2) operate on both success and failure cases, similar to a railway section that handles trains on both tracks.

PHP Implementation

The PHP Railway Programming library provides a robust implementation of these railway concepts through its Railway and Result classes. The Result class serves as our basic switch mechanism, while the Railway class provides the fluent interface for chaining operations. This implementation brings the elegance of functional programming's error handling to the PHP ecosystem, making it accessible to developers working with traditional object-oriented codebases.

Core Operations in Railway Programming

The map operation transforms values on the success track without affecting the failure track. It's like having a maintenance station that only services trains on the success track, letting failed trains pass by untouched on the failure track. This operation is perfect for simple transformations that can't fail. Conceptually, it accepts a 1-1 function and returns a 2-2 function. The lift operation transforms a regular one-track function into a switch function. Think of it as installing a safety system on a regular railway track - the function can now handle both success and failure cases. When we lift a function, we're essentially wrapping it in error handling capability, allowing it to participate in our two-track system. Conceptually, it accepts a 1-1 function and returns a 1-2 function. The bind operation is perhaps the most fundamental concept in ROP. It takes a switch function and adapts it to work with our two-track system. Imagine a railway junction where tracks can merge and split - bind ensures that success values continue on the success track while failures are automatically routed to the failure track. This operation is crucial for chaining multiple operations together while maintaining proper error handling. Conceptually, it accepts a switch 1-2 function and returns a 2-2 function. The tee operation is like a railway observation post - it allows us to perform side effects (like logging or monitoring) without affecting the train's journey on either track. It's particularly useful for debugging or adding analytics without disrupting the main flow of operations. Conceptually, it is a dead function that bypass the success or failure track. The tryCatch acts as a special kind of switch that can catch derailments (exceptions) and route them to the failure track. It's essential for integrating traditional try-catch error handling into our railway system, making it compatible with existing code that might throw exceptions. Conceptually, it accepts a 1-1 function and convert it into a 1-2 function. The plus and unite combinators are like complex railway junctions that can combine multiple tracks. Plus allows parallel processing of two separate railways, combining their results according to custom rules, and conceptually it accepts two 1-2 functions and returns a 1-2 function. The unite joins two railways sequentially, taking the result of the second railway if the first one succeeds. It conceptually accepts two 1-2 functions and join them into a 1-2 function. The doubleMap operation is a special kind of switch function that can handle both success and failure cases. It's like having a maintenance station that can service trains on both tracks, allowing us to transform values on both tracks without affecting the other. Conceptually, it accepts a 1-1 function and returns a 2-2 function.

Result Monad

The Result is a type that can be used to represent the result of a computation that can either succeed or fail. It is used for representing the computation in railway oriented programming flow.

Pattern matching

Pattern matching is a technique used to match the result of a computation against a set of patterns. It is used to extract the value of the result or handle the error case. Pattern matching in PHP Railway implementation serves as the final resolver for the two-track system, providing a clean way to extract values from either the success or failure track. The Railway::match method takes two callback functions: one for handling successful results and another for handling failures. This approach eliminates the need for manual checking of the Railway's state and provides a type-safe way to access the final values. In practical PHP applications, pattern matching becomes useful when we need to transform our Railway result into concrete actions or responses. For instance, when working with web frameworks, we can use pattern matching to either return a success response with the processed data or handle errors by throwing exceptions or returning error messages. This is more elegant than traditional conditional statements because it forces us to handle both cases explicitly and keeps the success and failure handling code clearly separated.

Practical Implementation: Room Reservation System

Let's explore a practical implementation of Railway Oriented Programming through a hotel room reservation system that we described in the Testing DCI with Behavior-Driven Development article. This example demonstrates how ROP can elegantly handle complex business processes with multiple potential failure points.

System Components

The reservation system consists of three main components:
  1. ReservationData Context
It acts as an immutable data container that holds all necessary information about a reservation, including room details, guest information, check-in/out dates, and various state data. The immutability is ensured through a withState method that creates new instances when state changes are needed. namespace App\Reservation; use Cake\I18n\DateTime; class ReservationData { public function __construct( public readonly array $room, public readonly array $primaryGuest, public readonly array $additionalGuests, public readonly DateTime $checkIn, public readonly DateTime $checkOut, private array $state = [] ) {} public function withState(string $key, mixed $value): self { $clone = clone $this; $clone->state[$key] = $value; return $clone; } public function getState(string $key): mixed { return $this->state[$key] ?? null; } }
  1. ReservationOperations
This class contains all the core business operations for the reservation process. Each operation is designed to work within the railway pattern, either returning successful results or failing gracefully. The operations include:
  • Availability validation and price calculation
  • Reservation creation in the database
  • Email confirmation sending
  • Loyalty points management
  • Audit logging
namespace App\Reservation; use Cake\Mailer\Mailer; use ROP\Railway; use Cake\ORM\TableRegistry; class ReservationOperations { public static function validateAvailability(ReservationData $data): Railway { $reservationsTable = TableRegistry::getTableLocator()->get('Reservations'); $existingReservation = $reservationsTable->find() ->where([ 'room_id' => $data->room['id'], 'status !=' => 'cancelled', ]) ->where(function ($exp) use ($data) { return $exp->or([ function ($exp) use ($data) { return $exp->between('check_in', $data->checkIn, $data->checkOut); }, function ($exp) use ($data) { return $exp->between('check_out', $data->checkIn, $data->checkOut); } ]); }) ->first(); if ($existingReservation) { return Railway::fail("Room is not available for selected dates"); } $totalGuests = count($data->additionalGuests) + 1; if ($totalGuests > $data->room['capacity']) { return Railway::fail( "Total number of guests ({$totalGuests}) exceeds room capacity ({$data->room['capacity']})" ); } $basePrice = $data->room['base_price'] * $data->checkIn->diffInDays($data->checkOut); $discount = match($data->primaryGuest['loyalty_level']) { 'gold' => 0.1, 'silver' => 0.05, default => 0 }; $finalPrice = $basePrice * (1 - $discount); return Railway::of($data->withState('total_price', $finalPrice)); } public static function createReservation(ReservationData $data): ReservationData { $reservationsTable = TableRegistry::getTableLocator()->get('Reservations'); $reservation = $reservationsTable->newEntity([ 'room_id' => $data->room['id'], 'primary_guest_id' => $data->primaryGuest['id'], 'check_in' => $data->checkIn, 'check_out' => $data->checkOut, 'status' => 'confirmed', 'total_price' => $data->getState('total_price'), 'reservation_guests' => array_map( fn($guest) => ['guest_id' => $guest['id']], $data->additionalGuests ), ]); if (!$reservationsTable->save($reservation)) { throw new \RuntimeException('Could not save reservation'); } return $data->withState('reservation_id', $reservation->id); } public static function logReservation(ReservationData $data): ReservationData { TableRegistry::getTableLocator()->get('Reservations')->logOperation( // ... ); return $data; } public static function sendConfirmationEmail(ReservationData $data): Railway { $result = rand(0,10); return $result > 2 ? Railway::of($data) : Railway::fail('Failed to send confirmation email'); } public static function updateGuestLoyaltyPoints(ReservationData $data): ReservationData { // ... return $data; } }
  1. ReservationController
This class acts as the controller for the reservation system. It handles the HTTP request, validates the input, and orchestrates the reservation process using the Railway class. The controller uses the ReservationOperations class to perform the necessary operations and handles the result of each operation using the Railway::match method. namespace App\Reservation; use ROP\Railway; class ReservationController { public function add() { $Rooms = $this->fetchTable('Rooms'); $Guests = $this->fetchTable('Guests'); $rooms = $Rooms->find('list')->where(['status' => 'available']); $guests = $Guests->find('list'); $this->set(compact('rooms', 'guests')); if ($this->request->is('post')) { try { $room = $Rooms->get($this->request->getData('room_id'))->toArray(); $primaryGuest = $Guests->get($this->request->getData('primary_guest_id'))->toArray(); $additionalGuests = []; if ($this->request->getData('additional_guest_ids')) { $additionalGuests = $Guests->find() ->where(['id IN' => $this->request->getData('additional_guest_ids')]) ->all() ->map(fn($guest) => $guest->toArray()) ->toArray(); } $data = new ReservationData( room: $room, primaryGuest: $primaryGuest, additionalGuests: $additionalGuests, checkIn: new DateTime($this->request->getData('check_in')), checkOut: new DateTime($this->request->getData('check_out')) ); $connection = $this->fetchTable('Reservations')->getConnection(); return $connection->transactional(function($connection) use ($data) { $result = ReservationOperations::validateAvailability($data) // First validate and calculate price ->map(fn($data) => $data->withState('reservation_time', time())) // Create reservation with error handling ->tryCatch(fn($data) => ReservationOperations::createReservation($data)) // Send confirmation email (might fail) ->bind(fn($data) => ReservationOperations::sendConfirmationEmail($data)) // Log the reservation (with error handling) ->tryCatch(fn($data) => ReservationOperations::logReservation($data)) // Update room status (simple transformation) ->map(fn($data) => $data->withState('room_status', 'occupied')) // Calculate loyalty points (simple transformation) ->map(fn($data) => $data->withState( 'loyalty_points', floor($data->getState('total_price') * 0.1) )) // Update guest loyalty points (with error handling) ->tryCatch(fn($data) => ReservationOperations::updateGuestLoyaltyPoints($data)) // Log all operations for audit ->tee(fn($data) => error_log(sprintf( "Reservation completed: %s, Points earned: %d", $data->getState('reservation_id'), $data->getState('loyalty_points') ))); return $result->match( success: function($data) { $this->Flash->success(__('Reservation confirmed! Your confirmation number is: {0}', $data->getState('reservation_id') )); return $this->redirect(['action' => 'view', $data->getState('reservation_id')]); }, failure: function($error) { if ($error instanceof \Exception) throw $error; throw new \RuntimeException($error); } ); }); } catch (\Exception $e) { $this->Flash->error(__('Unable to complete reservation: {0}', $e->getMessage())); } } } }

The Railway Flow

The reservation process showcases several key aspects of Railway Oriented Programming:
  1. Input Validation: The process begins with validating room availability and guest capacity, demonstrating how early failures can be handled gracefully.
  2. State Transformation: Throughout the process, the ReservationData object is transformed through various states while maintaining immutability.
  3. Error Handling: Each step can potentially fail, but the railway pattern keeps the error handling clean and predictable.
  4. Transaction Management: The entire process is wrapped in a database transaction, showing how ROP can work with traditional database operations.
  5. Side Effects: The pattern handles side effects (like sending emails and logging) in a controlled manner through the tee operation.
The sequence diagram illustrates how the Railway pattern creates a clear separation between success and failure paths, making it easier to reason about the system's behavior. This implementation shows that Railway Oriented Programming is not just a theoretical concept but a practical approach to handling complex business processes in real-world applications. sequenceDiagram participant C as Controller participant DB as Database participant E as Email participant R as Railway Track Note over R: Success Track ✅ Note over R: Failure Track ❌ C->>DB: Check Room Availability alt Room not available DB-->>R: ❌ "Room not available" R-->>C: Railway::fail else Room available DB-->>R: ✅ Room data Note over R: Validate Guest Count alt Exceeds capacity R-->>C: ❌ Railway::fail("Exceeds capacity") else Guest count OK R-->>C: ✅ Calculate price & set state C->>DB: Creating Reservation alt Save successful DB-->>R: ✅ reservation_id C->>E: Send Confirmation alt Email sent E-->>R: ✅ Continue else Email failed E-->>R: ❌ "Failed to send email" R-->>C: Railway::fail end C->>DB: Adding Audit Log DB-->>R: ✅ Continue C->>DB: Updating Loyalty Points alt Update successful DB-->>R: ✅ Final success R-->>C: Railway::of(data) else Update failed DB-->>R: ❌ "Failed to update points" R-->>C: Railway::fail end else Save failed DB-->>R: ❌ "Could not save reservation" R-->>C: Railway::fail end end end This room reservation system demonstrates several key benefits of Railway Oriented Programming:
  1. Clarity: The code clearly shows the flow of operations and potential failure points, making it easier to understand and maintain.
  2. Robustness: Error handling is comprehensive and consistent throughout the entire process.
  3. Maintainability: New steps can be easily added to the reservation process by extending the railway chain.
  4. Transaction Safety: The pattern works seamlessly with database transactions, ensuring data consistency.
  5. Testability: Each operation is isolated and can be tested independently, while the entire flow can be tested as a unit.
This example serves as a blueprint for implementing similar patterns in other business domains where complex workflows and error handling are required. It demonstrates how functional programming concepts can be successfully applied in a traditionally object-oriented environment like PHP.

Demo Project for Article

The examples used in this article are located at https://github.com/skie/cakephp-dci/tree/3.0.0 and available for testing. The controller code is located at src/Controller/RopReservationsController.php.

Conclusion

Railway Oriented Programming represents a paradigm shift in error handling, moving away from imperative try-catch blocks toward a more functional, flow-based approach. By visualizing our program as a railway system, we gain a powerful metaphor for understanding and managing the complexity of error handling in our applications. The PHP implementation of ROP brings these concepts to the PHP community, enabling developers to write more maintainable, readable, and robust code. This article is part of the CakeDC Advent Calendar 2024 (December 23rd 2024)

Using RBAC and rules for authorization

This article is part of the CakeDC Advent Calendar 2024 (December 22nd 2024) Authorization can become a complex topic. If you go over the options described in the CakePHP Book, https://book.cakephp.org/authorization/3/en/index.html and the specific tutorial https://book.cakephp.org/5/en/tutorials-and-examples/cms/authorization.html, you'll see that there are options to define the authorization in a very flexible way. In CakePHP, the Authorization Plugin will allow you to define subjects of authorization, entities that want to get access to one of these subjects, and rules to determine if the entities can have access to a given subject. Many CakePHP applications coming from versions 1,2,3 don't require a lot of flexibility because they define:

  • Subject: a plugin/prefix/Controller/action, like a "url" in our site, for example: "/admin/users/add"
  • Entity: a logged in user, or a guest user who is not logged in yet. Usually we'll group the users in a role, to allow assigning permissions per role
  • Rule: a function, returning true or false
In these cases, we can build an authorization table, like URL Role CanAccess? /admin/users/index admins yes /admin/users/index users no ... To apply these concepts in you CakePHP Application, you can use existing plugins like: But, following our spirit of simplicity, let's imagine you've implemented the CakePHP CMS Tutorial https://book.cakephp.org/5/en/tutorials-and-examples.html. Or, you can clone the project from here: https://github.com/cakephp/cms-tutorial. In this case, to enable url base authentication we would need to change:
  • composer require cakedc/auth
  • Update the AuthorizationService configuration to map the request object with a collection of policies
// src/Application::getAuthorizationService public function getAuthorizationService(ServerRequestInterface $request): AuthorizationServiceInterface { $map = new MapResolver(); $map->map( ServerRequest::class, new CollectionPolicy([ SuperuserPolicy::class, new RbacPolicy([ [ 'role' => '*', 'controller' => 'Pages', 'action' => 'display', ], // other rules // the rules could be stored in a configuration file or database ]), ]) ); $orm = new OrmResolver(); $resolver = new ResolverCollection([ $map, $orm, ]); return new AuthorizationService($resolver); }
  • The last piece to this approach would be adding the RequestAuthorizationMiddleware:
// src/Application::middleware public function middleware($middlewareQueue): \Cake\Http\MiddlewareQueue { $middlewareQueue // ... ->add(new AuthorizationMiddleware($this)) ->add(new RequestAuthorizationMiddleware());

How will it work?

For each request to your application, the RequestAuthorizationMiddleware will trigger an authorization check, looking for a Policy in the MapResolver. We'll check first if the user has the column is_superuser set as true, and if not, we'll use the Rbac to check if the user can access the given URL, using the routing parameters. The Rbac class provides a lot of flexibility to configure the rules, see https://github.com/CakeDC/auth/blob/8.next-cake5/Docs/Documentation/Rbac.md. Note: the users table included in the CMS Tutorial does not include a role column. If you want to define roles, you would need to add it using a Migration.

Recap

We've used the cms tutorial application from the CakePHP Book to implement a Request Authorization Rbac policy for simple applications looking for a way to determine if a given URL can be accessed by a user role. This article is part of the CakeDC Advent Calendar 2024 (December 22nd 2024)

We Bake with CakePHP