CakeDC Blog

TIPS, INSIGHTS AND THE LATEST FROM THE EXPERTS BEHIND CAKEPHP

Testing DCI with Behavior-Driven Development

This article is part of the CakeDC Advent Calendar 2024 (December 19th 2024)

In our previous article, we explored the Data-Context-Interaction (DCI) pattern and its implementation in PHP using CakePHP. We demonstrated how DCI helps separate data structures from their runtime behaviors through roles and contexts, using a money transfer system as an example. Now, let's dive into testing DCI implementations using Behavior-Driven Development (BDD) with Behat, exploring a practical hotel room reservation system.

Room Reservation System Overview

The room reservation system demonstrates DCI's power in managing complex business rules and interactions. In this system, rooms and guests are our core data objects, while the reservation process involves multiple roles and behaviors. A room can be reservable under certain conditions (availability, capacity), and guests can have different privileges based on their loyalty levels. The reservation context orchestrates these interactions, ensuring business rules are followed and the system maintains consistency.

Database Structure

The database schema reflects our domain model with proper relationships between entities:

erDiagram
    rooms {
        id integer PK
        number varchar(10)
        type varchar(50)
        capacity integer
        base_price decimal
        status varchar(20)
        created datetime
        modified datetime
    }

    guests {
        id integer PK
        name varchar(100)
        email varchar(100)
        phone varchar(20)
        loyalty_level varchar(20)
        created datetime
        modified datetime
    }

    reservations {
        id integer PK
        room_id integer FK
        primary_guest_id integer FK
        check_in date
        check_out date
        status varchar(20)
        total_price decimal
        created datetime
        modified datetime
    }

    reservation_guests {
        id integer PK
        reservation_id integer FK
        guest_id integer FK
        created datetime
    }

    audit_logs {
        id integer PK
        model varchar(100)
        foreign_key integer
        operation varchar(50)
        data json
        created datetime
    }

    reservations ||--|| rooms : "has"
    reservations ||--|| guests : "primary guest"
    reservation_guests }|--|| reservations : "belongs to"
    reservation_guests }|--|| guests : "includes"
    audit_logs }|--|| reservations : "logs"

Key aspects of this schema:

  • Rooms table stores physical hotel rooms with their properties
  • Guests table maintains customer information including loyalty status
  • Reservations table handles booking details with pricing
  • Reservation_guests enables multiple guests per reservation
  • Audit_logs provides system-wide operation tracking
classDiagram
    class Room {
        +String number
        +String type
        +Integer capacity
        +Decimal basePrice
        +String status
    }

    class Guest {
        +String name
        +String email
        +String phone
        +String loyaltyLevel
    }

    class Reservation {
        +Room room
        +Guest primaryGuest
        +Date checkIn
        +Date checkOut
        +String status
        +Decimal totalPrice
    }

    class ReservationGuest {
        +Reservation reservation
        +Guest guest
    }

    Reservation --> Room
    Reservation --> Guest
    ReservationGuest --> Reservation
    ReservationGuest --> Guest

The class diagram above shows our core data model. Each entity has specific attributes that define its state, but the interesting part comes with how these objects interact during the reservation process. Let's examine how DCI roles enhance this basic structure:

classDiagram
    class ReservableRoom {
        +isAvailableForDates(checkIn, checkOut)
        +canAccommodateGuests(guestCount)
        +calculatePrice(checkIn, checkOut)
    }

    class ReservingGuest {
        +canMakeReservation()
        +calculateDiscount(basePrice)
    }

    class RoomReservationContext {
        +Room room
        +Guest primaryGuest
        +List~Guest~ additionalGuests
        +Date checkIn
        +Date checkOut
        +execute()
    }

    Room ..|> ReservableRoom : implements
    Guest ..|> ReservingGuest : implements
    RoomReservationContext --> ReservableRoom : uses
    RoomReservationContext --> ReservingGuest : uses

The reservation process involves multiple interactions between objects, each playing their specific roles. The sequence diagram below illustrates how these components work together:

sequenceDiagram
    participant RC as ReservationsController
    participant RRC as RoomReservationContext
    participant R as Room
    participant G as Guest
    participant RR as ReservableRoom
    participant RG as ReservingGuest
    participant DB as Database

    RC->>RRC: new RoomReservationContext(room, guest, dates)
    activate RRC

    RRC->>R: addRole('ReservableRoom')
    RRC->>G: addRole('ReservingGuest')

    RC->>RRC: execute()

    RRC->>R: isAvailableForDates(checkIn, checkOut)
    R->>RR: isAvailableForDates(checkIn, checkOut)
    RR-->>RRC: true/false

    alt Room is available
        RRC->>R: canAccommodateGuests(guestCount)
        R->>RR: canAccommodateGuests(guestCount)
        RR-->>RRC: true/false

        alt Can accommodate guests
            RRC->>G: canMakeReservation()
            G->>RG: canMakeReservation()
            RG-->>RRC: true/false

            alt Guest can make reservation
                RRC->>R: calculatePrice(checkIn, checkOut)
                R->>RR: calculatePrice(checkIn, checkOut)
                RR-->>RRC: basePrice

                RRC->>G: calculateDiscount(basePrice)
                G->>RG: calculateDiscount(basePrice)
                RG-->>RRC: discount

                RRC->>DB: save reservation
                DB-->>RRC: success
            else
                RRC-->>RC: throw GuestCannotReserveException
            end
        else
            RRC-->>RC: throw CapacityExceededException
        end
    else
        RRC-->>RC: throw RoomNotAvailableException
    end

    RRC->>R: removeRole('ReservableRoom')
    RRC->>G: removeRole('ReservingGuest')
    deactivate RRC

This sequence diagram demonstrates the complete reservation flow, including role attachment, validation checks, price calculations, and proper error handling. Each step ensures that business rules are followed and the system maintains consistency.

Testing with Behavior-Driven Development

While our DCI implementation provides clear separation of concerns and maintainable code, we need to ensure it works correctly through comprehensive testing. Behavior-Driven Development (BDD) with Behat is particularly well-suited for testing DCI implementations because both approaches focus on behaviors and interactions.

Understanding Behat and Gherkin

Behat is a PHP framework for BDD, which allows us to write tests in natural language using Gherkin syntax. Gherkin is a business-readable domain-specific language that lets you describe software's behavior without detailing how that behavior is implemented. This aligns perfectly with DCI's focus on separating what objects are from what they do.

A typical Gherkin feature file consists of:

  • Feature: A description of the functionality being tested
  • Scenario: A specific situation being tested
  • Given: The initial context
  • When: The action being taken
  • Then: The expected outcome

Setting Up Behat Testing Environment

First, add the required dependencies to your composer.json:

{
    "require-dev": {
        "behat/behat": "^3.13",
        "behat/mink-extension": "^2.3",
        "behat/mink-browserkit-driver": "^2.1",
        "dmore/behat-chrome-extension": "^1.4"
    }
}

Here's how we configure Behat for our project:

# behat.yml
default:
  autoload:
    "": "%paths.base%/tests/Behat"
  suites:
    reservation:
      paths:
        features: "%paths.base%/tests/Behat/Features/Reservation"
      contexts:
        - App\Test\Behat\Context\ReservationContext
        - App\Test\Behat\Context\DatabaseContext
  extensions:
    Behat\MinkExtension:
      base_url: 'http://localhost'
      sessions:
        default:
          browser_stack: ~

Complete Behat Test Implementation

Our test implementation consists of several key components that work together to verify our DCI implementation:

Base Test Context Setup

The BaseContext class provides basic test infrastructure, handling test environment initialization and database connections. It loads the application bootstrap file and configures the test environment, including database connections and debug settings.

// tests/Behat/Context/BaseContext.php
<?php
declare(strict_types=1);

namespace App\Test\Behat\Context;

use Behat\Behat\Context\Context;
use Cake\Core\Configure;
use Cake\ORM\TableRegistry;
use Cake\TestSuite\ConnectionHelper;

abstract class BaseContext implements Context
{
    public function __construct(string $bootstrap = null)
    {
    }

    protected function initialize(): void
    {
        require_once dirname(__DIR__, 3) . '/tests/bootstrap.php';
        require_once dirname(dirname(dirname(__DIR__))) . '/config/bootstrap.php';
        ConnectionHelper::addTestAliases();
        Configure::write('debug', true);
    }

    protected function getTableLocator()
    {
        return TableRegistry::getTableLocator();
    }
}

Database Management and Fixtures

The DatabaseContext class handles database setup and cleanup, including table creation, data insertion, and deletion. It uses fixtures to populate the database with initial data, ensuring tests start with a known state. This setup allows for consistent testing conditions across different scenarios.

// tests/Behat/Context/DatabaseContext.php
<?php
declare(strict_types=1);

namespace App\Test\Behat\Context;

use Behat\Behat\Hook\Scope\BeforeScenarioScope;
use Behat\Gherkin\Node\TableNode;
use Cake\ORM\TableRegistry;

class DatabaseContext extends BaseContext
{
    private $tables = [
        'audit_logs',
        'reservation_guests',
        'reservations',
        'guests',
        'rooms',
    ];

    /**
     * @BeforeScenario
     */
    public function initializeTest(BeforeScenarioScope $scope): void
    {
        $this->initialize();
        $this->clearDatabase();
    }

    /**
     * @BeforeScenario
     */
    public function clearDatabase(): void
    {
        $connection = TableRegistry::getTableLocator()
            ->get('Reservations')
            ->getConnection();

        $connection->execute('PRAGMA foreign_keys = OFF');
        foreach ($this->tables as $tableName) {
            TableRegistry::getTableLocator()->get($tableName)->deleteAll([]);
        }
        $connection->execute('PRAGMA foreign_keys = ON');
    }

    /**
     * @Given the following rooms exist:
     */
    public function theFollowingRoomsExist(TableNode $rooms): void
    {
        $roomsTable = TableRegistry::getTableLocator()->get('Rooms');
        $headers = $rooms->getRow(0);
        foreach ($rooms->getRows() as $i => $room) {
            if ($i === 0) {
                continue;
            }
            $room = array_combine($headers, $room);
            $entity = $roomsTable->newEntity($room);
            $roomsTable->save($entity);
        }
    }

    /**
     * @Given the following guests exist:
     */
    public function theFollowingGuestsExist(TableNode $guests)
    {
        $guestsTable = TableRegistry::getTableLocator()->get('Guests');
        $headers = $guests->getRow(0);
        foreach ($guests->getRows() as $i => $guest) {
            if ($i === 0) {
                continue;
            }
            $guest = array_combine($headers, $guest);
            $entity = $guestsTable->newEntity($guest);
            $guestsTable->save($entity);
        }
    }

    /**
     * @Given the following reservations exist:
     */
    public function theFollowingReservationsExist(TableNode $reservations)
    {
        $reservationsTable = TableRegistry::getTableLocator()->get('Reservations');
        $headers = $reservations->getRow(0);
        foreach ($reservations->getRows() as $i => $reservation) {
            if ($i === 0) {
                continue;
            }
            $reservation = array_combine($headers, $reservation);
            $entity = $reservationsTable->newEntity($reservation);
            $reservationsTable->save($entity);
        }
    }
}

Reservation Testing Context

ReservationContext implements the business logic testing for our room reservation system. It manages the test workflow for reservation creation, guest management, and verification of reservation outcomes. This context translates Gherkin steps into actual system operations, handling authentication, room selection, guest assignment, and reservation confirmation. It also captures and verifies error conditions, ensuring our DCI roles and contexts behave correctly under various scenarios.

// tests/Behat/Context/ReservationContext.php
<?php
declare(strict_types=1);

namespace App\Test\Behat\Context;

use App\Context\RoomReservation\RoomReservationContext;
use App\Model\Entity\Guest;
use App\Model\Entity\Room;
use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\TableNode;
use Behat\MinkExtension\Context\RawMinkContext;
use Cake\I18n\DateTime;
use Cake\ORM\TableRegistry;
use PHPUnit\Framework\Assert;

class ReservationContext extends RawMinkContext implements Context
{
    private ?Guest $authenticatedGuest = null;
    private ?Room $selectedRoom = null;
    private array $additionalGuests = [];
    private ?string $lastError = null;
    private ?float $totalPrice = null;
    private ?array $reservationDates = null;
    private ?array $lastLoggedOperation = null;

    /**
     * @Given I am authenticated as :name
     */
    public function iAmAuthenticatedAs(string $name): void
    {
        $this->authenticatedGuest = TableRegistry::getTableLocator()
            ->get('Guests')
            ->find()
            ->where(['name' => $name])
            ->firstOrFail();
    }

    /**
     * @When I try to reserve room :number for the following stay:
     */
    public function iTryToReserveRoomForTheFollowingStay(string $number, TableNode $table): void
    {
        $this->selectedRoom = TableRegistry::getTableLocator()
            ->get('Rooms')
            ->find()
            ->where(['number' => $number])
            ->contain(['Reservations'])
            ->firstOrFail();

        $this->reservationDates = $table->getRowsHash();
    }

    /**
     * @When I add :name as an additional guest
     */
    public function iAddAsAnAdditionalGuest(string $name): void
    {
        $guest = TableRegistry::getTableLocator()
            ->get('Guests')
            ->find()
            ->where(['name' => $name])
            ->firstOrFail();

        $this->additionalGuests[] = $guest;
    }

    private function executeReservation(): void
    {
        if (!$this->selectedRoom || !$this->reservationDates || !$this->authenticatedGuest) {
            return;
        }

        try {
            $context = new RoomReservationContext(
                $this->selectedRoom,
                $this->authenticatedGuest,
                $this->additionalGuests,
                new DateTime($this->reservationDates['check_in']),
                new DateTime($this->reservationDates['check_out'])
            );

            $reservation = $context->execute();
            $this->totalPrice = (float)$reservation->total_price;
            $this->lastError = null;
        } catch (\Exception $e) {
            $this->lastError = $e->getMessage();
        }
    }

    /**
     * @Then the reservation should be confirmed
     */
    public function theReservationShouldBeConfirmed(): void
    {
        $this->executeReservation();

        if ($this->lastError !== null) {
            throw new \Exception("Expected reservation to be confirmed but got error: {$this->lastError}");
        }
    }

    /**
     * @Then the total price should be :price
     */
    public function theTotalPriceShouldBe(string $price): void
    {
        $this->executeReservation();

        $expectedPrice = (float)str_replace('"', '', $price);
        if ($this->totalPrice !== $expectedPrice) {
            throw new \Exception(
                "Expected price to be {$expectedPrice} but got {$this->totalPrice}"
            );
        }
    }

    /**
     * @Then I should see an error :message
     */
    public function iShouldSeeAnError(string $message): void
    {
        $this->executeReservation();

        if ($this->lastError === null) {
            throw new \Exception("Expected error but none was thrown");
        }
        if (strpos($this->lastError, $message) === false) {
            throw new \Exception(
                "Expected error message '{$message}' but got '{$this->lastError}'"
            );
        }
    }

    /**
     * @Then the following operation should be logged:
     */
    public function theFollowingOperationShouldBeLogged(TableNode $table): void
    {
        $expectedLog = $table->getRowsHash();

        $AuditLogs = TableRegistry::getTableLocator()->get('AuditLogs');
        $lastOperation = $AuditLogs->find()->orderByDesc('created')->first();

        Assert::assertNotNull($lastOperation, 'No operation was logged');
        Assert::assertEquals($expectedLog['model'], $lastOperation->model);
        Assert::assertEquals($expectedLog['operation'], $lastOperation->operation);

        $expectedData = [];
        foreach (explode(', ', $expectedLog['data']) as $pair) {
            [$key, $value] = explode('=', $pair);
            $expectedData[$key] = $value;
        }

        Assert::assertEquals($expectedData, json_decode($lastOperation->data, true));
    }
}

And here's the Gherkin feature that describes tests for our reservation system:

# tests/Behat/Features/Reservation/room_reservation.feature
Feature: Room Reservation
    In order to stay at the hotel
    As a guest
    I need to be able to make room reservations

    Background:
        Given the following rooms exist:
            | id | number | type     | capacity | base_price | status    |
            | 1  | 101    | standard | 2        | 100.00     | available |
            | 2  | 201    | suite    | 4        | 200.00     | available |
            | 3  | 301    | deluxe   | 3        | 150.00     | available |
        And the following guests exist:
            | id | name          | email              | phone       | loyalty_level |
            | 1  | John Smith    | [email protected]   | 1234567890  | gold          |
            | 2  | Jane Doe      | [email protected]   | 0987654321  | silver        |
            | 3  | Bob Wilson    | [email protected]    | 5555555555  | bronze        |
        And the following reservations exist:
            | id | room_id | check_in    | check_out   | status    | guest_id | total_price | primary_guest_id |
            | 1  | 2       | 2025-06-01  | 2025-06-05  | confirmed | 2        | 200.00      | 2                |

    Scenario: Successfully make a room reservation
        Given I am authenticated as "John Smith"
        When I try to reserve room "101" for the following stay:
            | check_in    | 2025-07-01 |
            | check_out   | 2025-07-05 |
        And I add "Bob Wilson" as an additional guest
        Then the reservation should be confirmed
        And the total price should be "360.00"
        And the following operation should be logged:
            | model         | Reservations         |
            | operation     | reservation_created  |
            | data          | room_number=101, guest_name=John Smith, check_in=2025-07-01, check_out=2025-07-05, total_price=360, additional_guests=1 |

    Scenario: Cannot reserve an already booked room
        Given I am authenticated as "John Smith"
        When I try to reserve room "201" for the following stay:
            | check_in    | 2025-06-03 |
            | check_out   | 2025-06-07 |
        Then I should see an error "Room is not available for selected dates"

    Scenario: Cannot exceed room capacity
        Given I am authenticated as "John Smith"
        When I try to reserve room "101" for the following stay:
            | check_in    | 2025-08-01 |
            | check_out   | 2025-08-05 |
        And I add "Jane Doe" as an additional guest
        And I add "Bob Wilson" as an additional guest
        Then I should see an error "Total number of guests (3) exceeds room capacity (2)"

    Scenario: Apply loyalty discounts correctly
        Given I am authenticated as "Jane Doe"
        When I try to reserve room "301" for the following stay:
            | check_in    | 2025-09-01 |
            | check_out   | 2025-09-04 |
        Then the reservation should be confirmed
        And the total price should be "427.5"
        And the following operation should be logged:
            | model         | Reservations         |
            | operation     | reservation_created  |
            | data          | room_number=301, guest_name=Jane Doe, check_in=2025-09-01, check_out=2025-09-04, total_price=427.5, additional_guests=0 |

The test context mirrors our DCI implementation in several ways:

  1. Role Assignment: Just as our DCI implementation attaches roles to objects, our test context manages the state of actors (guests and rooms) involved in the reservation process.

  2. Context Creation: The test creates a RoomReservationContext with all necessary participants, similar to how our application would in production.

  3. Behavior Verification: Tests verify both successful scenarios and error conditions, ensuring our DCI roles enforce business rules correctly.

Last two scenarios demonstrate how BDD tests can effectively verify:

  1. Role Constraints: The ReservableRoom role's capacity constraints
  2. Role Behaviors: The ReservingGuest role's discount calculations
  3. Context Orchestration: The RoomReservationContext's coordination of multiple roles

The combination of DCI and BDD provides several benefits:

  • Clear Specifications: Gherkin scenarios serve as living documentation of system behavior
  • Role Verification: Each test verifies that roles implement their responsibilities correctly
  • Context Validation: Tests ensure that contexts properly orchestrate role interactions
  • Business Rule Enforcement: Scenarios verify that business rules are properly enforced through roles

Money Transfer Testing Example

Before concluding, let's look at how we tested the money transfer system from our previous article. This example demonstrates how BDD tests can effectively verify DCI pattern implementation:

Feature: Money Transfer
    In order to move money between accounts
    As an account holder
    I need to be able to transfer funds between accounts

    # Setup initial test data
    Background:
        Given the following accounts exist:
            | id | balance  | account_type | status | is_frozen |
            | 1  | 1000.00  | checking     | active | false     |
            | 2  | 500.00   | savings      | active | false     |
            | 3  | 200.00   | checking     | active | true      |
            | 4  | 300.00   | deposit_only | active | false     |

    # Tests basic transfer functionality and audit logging
    Scenario: Successful transfer between active accounts
        When I transfer "200.00" from account "1" to account "2"
        Then account "1" should have balance of "800.00"
        And account "2" should have balance of "700.00"
        # Verifies that all transfer steps are properly logged
        And an audit log should exist with:
            | foreign_key | operation       |
            | 1           | pre_withdrawal  |
            | 1           | post_withdrawal |
            | 2           | pre_deposit     |
            | 2           | post_deposit    |

    # Verifies role constraints - frozen accounts cannot perform withdrawals
    Scenario: Cannot transfer from frozen account
        When I try to transfer "100.00" from account "3" to account "2"
        Then I should get an error "Source cannot withdraw this amount"
        And account "3" should have balance of "200.00"
        And account "2" should have balance of "500.00"

    # Verifies business rule - insufficient funds
    Scenario: Cannot transfer more than available balance
        When I try to transfer "1200.00" from account "1" to account "2"
        Then I should get an error "Source cannot withdraw this amount"
        And account "1" should have balance of "1000.00"
        And account "2" should have balance of "500.00"

This feature file tests several key aspects of our DCI implementation:

  1. Role Behavior Testing

    • TransferSource role's withdrawal capabilities
    • TransferDestination role's deposit functionality
    • Role constraints (frozen accounts, insufficient funds)
  2. Context Orchestration

    • Proper execution of the transfer process
    • Transaction atomicity (all-or-nothing transfers)
    • Proper cleanup of role assignments
  3. Business Rules Verification

    • Balance constraints
    • Account status restrictions
    • Audit trail requirements
  4. Error Handling

    • Proper error messages for various failure scenarios
    • State preservation after failed transfers
    • Role constraint violations

These tests ensure that our DCI implementation maintains system integrity while enforcing business rules through role behaviors and context coordination.

Conclusion

Testing DCI implementations with Behat creates a perfect match between how we build our software and how we test it. Let's look at why this combination works so well:

First, Behat's behavior-driven approach matches naturally with DCI's focus on what objects do rather than just what they are. When we write tests in Gherkin language, we describe actions and their results - just like DCI describes roles and their behaviors. This makes our tests easier to understand and write because they follow the same thinking pattern as our code.

Second, both DCI and BDD focus on real-world scenarios. DCI helps us organize code around actual use cases (like making a room reservation or transferring money), while Behat lets us write tests that directly reflect these same use cases. This means our tests read like a story of what the system should do, making them valuable not just for testing but also as living documentation.

Additionally, the way Behat structures tests with "Given-When-Then" steps fits perfectly with how DCI contexts work:

  • "Given" steps set up our data objects
  • "When" steps trigger the context's actions
  • "Then" steps verify that roles performed their behaviors correctly

This natural alignment between DCI and BDD testing makes our development process more straightforward and our tests more reliable. We can be confident that our tests are checking the right things because they're structured in the same way as the system they're testing.

Demo Project for Article

The complete example, including all tests and implementations, is available at: https://github.com/skie/cakephp-dci.

This article is part of the CakeDC Advent Calendar 2024 (December 19th 2024)

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