CakeDC Blog

TIPS, INSIGHTS AND THE LATEST FROM THE EXPERTS BEHIND CAKEPHP

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)

Latest articles

Goodbye to 2025!

Well bakers… another advent calendar is coming to an end. I hope you enjoyed all of the topics covered each day. We are also closing the year with so much gratitude.    2025 was the 20th year of CakePHP, can you believe it? We had an amazing year with our team, the community and the CakePHP core. It was great connecting with those who attended CakeFest in Madrid, and we hope to have the opportunity to see more of you in 2026.    I cannot let the year end without getting a little sentimental. There is no better way to say it… THANK YOU. Thank you to the team who worked so hard, the core team that keeps pumping out releases, and most of all … thank you to our clients that trust us with their projects. CakeDC is successful because of the strong relationships we build with our network, and we hope to continue working with all of you for many years.    There are a lot of great things still to come in year 21! Could 2026 will be bringing us CakePHP 6?! Considering 2 is rhe legal drinking age in the US, maybe CakePHP 6 should be beer cake? Delicious. Stay tuned to find out.    Before I go, I am leaving you with something special. A note from Larry!   As we close out this year, I just want to say thank you from the bottom of my heart. Twenty years ago, CakePHP started as a simple idea shared by a few of us who wanted to make building on the web easier and more enjoyable. Seeing how far it has come, and more importantly, seeing how many lives and careers it has impacted, is something I never take for granted. I am deeply grateful for our team, the core contributors, the community, and our clients who continue to believe in what we do. You are the reason CakePHP and CakeDC are still here, still growing, and still relevant after two decades. Here is to what we have built together, and to what is still ahead. Thank you for being part of this journey. Larry

Pagination of multiple queries in CakePHP

Pagination of multiple queries in CakePHP

A less typical use case for pagination in an appication is the need to paginate multiples queries. In CakePHP you can achieve this with pagination scopes.

Users list

Lest use as an example a simple users list. // src/Controller/UsersController.php class UsersController extends AppController { protected array $paginate = [ 'limit' => 25, ]; public function index() { // Default model pagination $this->set('users', $this->paginate($this->Users)); } } // templates/Users/index.php <h2><?= __('Users list') ?>/h2> <table> <thead> <tr> <th><?= $this->Paginator->sort('name', __('Name')) ?></th> <th><?= $this->Paginator->sort('email', __('Email')) ?></th> <th><?= $this->Paginator->sort('active', __('Active')) ?></th> </tr> </thead> <tbody> <?php foreach ($users as $user): ?> <tr> <td><?= h($user->name) ?></td> <td><?= h($user->email) ?></td> <td><?= $user->active ? 'Yes' : 'No' ?></td> </tr> <?php endforeach; ?> </tbody> </table> <?= $this->Paginator->counter() ?> <?= $this->Paginator->prev('« Previous') ?> <?= $this->Paginator->numbers() ?> <?= $this->Paginator->next('Next »') ?>

Pagination of multiple queries

Now, we want to display two paginated tables, one with the active users and the other with the inactive ones. // src/Controller/UsersController.php class UsersController extends AppController { protected array $paginate = [ 'Users' => [ 'scope' => 'active_users', 'limit' => 25, ], 'InactiveUsers' => [ 'scope' => 'inactive_users', 'limit' => 10, ], ]; public function index() { $activeUsers = $this->paginate( $this->Users->find()->where(['active' => true]), [scope: 'active_users'] ); // Load an additional table object with the custom alias set in the paginate property $inactiveUsersTable = $this->fetchTable('InactiveUsers', [ 'className' => \App\Model\Table\UsersTable::class, 'table' => 'users', 'entityClass' => 'App\Model\Entity\User', ]); $inactiveUsers = $this->paginate( $inactiveUsersTable->find()->where(['active' => false]), [scope: 'inactive_users'] ); $this->set(compact('users', 'inactiveUsers')); } } // templates/Users/index.php <?php // call `setPaginated` first with the results to be displayed next, so the paginator use the correct scope for the links $this->Paginator->setPaginated($users); ?> <h2><?= __('Active Users') ?>/h2> <table> <thead> <tr> <th><?= $this->Paginator->sort('name', __('Name')) ?></th> <th><?= $this->Paginator->sort('email', __('Email')) ?></th> <th><?= $this->Paginator->sort('active', __('Active')) ?></th> </tr> </thead> <tbody> <?php foreach ($users as $user): ?> <tr> <td><?= h($user->name) ?></td> <td><?= h($user->email) ?></td> <td><?= $user->active ? 'Yes' : 'No' ?></td> </tr> <?php endforeach; ?> </tbody> </table> <?= $this->Paginator->counter() ?> <?= $this->Paginator->prev('« Previous') ?> <?= $this->Paginator->numbers() ?> <?= $this->Paginator->next('Next »') ?> <?php // call `setPaginated` first with the results to be displayed next, so the paginator use the correct scope for the links $this->Paginator->setPaginated($inactiveUsers); ?> <h2><?= __('Inactive Users') ?>/h2> <table> <thead> <tr> <th><?= $this->Paginator->sort('name', __('Name')) ?></th> <th><?= $this->Paginator->sort('email', __('Email')) ?></th> <th><?= $this->Paginator->sort('active', __('Active')) ?></th> </tr> </thead> <tbody> <?php foreach ($inactiveUsers as $inactiveUser): ?> <tr> <td><?= h($inactiveUser->name) ?></td> <td><?= h($inactiveUser->email) ?></td> <td><?= $inactiveUser->active ? 'Yes' : 'No' ?></td> </tr> <?php endforeach; ?> </tbody> </table> <?= $this->Paginator->counter() ?> <?= $this->Paginator->prev('« Previous') ?> <?= $this->Paginator->numbers() ?> <?= $this->Paginator->next('Next »') ?> And with this you have two paginated tables in the same request.

Clean DI in CakePHP 5.3: Say Goodbye to fetchTable()

This article is part of the CakeDC Advent Calendar 2025 (December 23rd, 2025)

Introduction: The Death of the "Hidden" Dependency

For years, accessing data in CakePHP meant "grabbing" it from the global state. Whether using TableRegistry::getTableLocator()->get() or the LocatorAwareTrait’s $this->fetchTable(), your classes reached out to a locator to find what they needed. While convenient, this created hidden dependencies. A class constructor might look empty, despite the class being secretly reliant on multiple database tables. This made unit testing cumbersome, forcing you to stub the global TableLocator just to inject a mock. CakePHP 5.3 changes the game with Inversion of Control. With the framework currently in its Release Candidate (RC) stage and a stable release expected soon, now is the perfect time to explore these architectural improvements. By using the new TableContainer as a delegate for your PSR-11 container, tables can now be automatically injected directly into your constructors. This shift to explicit dependencies makes your code cleaner, fully type-hinted, and ready for modern testing standards. The Old Way (Hidden Dependency): public function execute() { $users = $this->fetchTable('Users'); // Where did this come from? } The 5.3 Way (Explicit Dependency): public function __construct(protected UsersTable $users) {} public function execute() { $this->users->find(); // Explicit and testable. }

Enabling the Delegate

Open src/Application.php and update the services() method by delegating table resolution to the TableContainer. // src/Application.php use Cake\ORM\TableContainer; public function services(ContainerInterface $container): void { // Register the TableContainer as a delegate $container->delegate(new TableContainer()); }

How it works under the hood

When you type-hint a class ending in Table (e.g., UsersTable), the main PSR-11 container doesn't initially know how to instantiate it. Because you've registered a delegate, it passes the request to the TableContainer, which then:
  1. Validates: It verifies the class name and ensures it is a subclass of \Cake\ORM\Table.
  2. Locates: It uses the TableLocator to fetch the correct instance (handling all the usual CakePHP ORM configuration behind the scenes).
  3. Resolves: It returns the fully configured Table object back to the main container to be injected.
Note: The naming convention is strict. The TableContainer specifically looks for the Table suffix. If you have a custom class that extends the base Table class but is named UsersRepository, the delegate will skip it, and the container will fail to resolve the dependency.

Practical Example: Cleaner Services

Now, your domain services no longer need to know about the LocatorAwareTrait. They simply ask for what they need. namespace App\Service; use App\Model\Table\UsersTable; class UserManagerService { // No more TableRegistry::get() or $this->fetchTable() public function __construct( protected UsersTable $users ) {} public function activateUser(int $id): void { $user = $this->users->get($id); // ... logic } } Next, open src/Application.php and update the services() method by delegating table resolution to the TableContainer. // src/Application.php use App\Model\Table\UsersTable; use App\Service\UserManagerService; use Cake\ORM\TableContainer; public function services(ContainerInterface $container): void { // Register the TableContainer as a delegate $container->delegate(new TableContainer()); // Register your service with the table as constructor argument $container ->add(UserManagerService::class) ->addArgument(UsersTable::class); }

Why this is a game changer for Testing

Because the table is injected via the constructor, you can now swap it for a mock effortlessly in your test suite without touching the global state of the application. $mockUsers = $this->createMock(UsersTable::class); $service = new UserManagerService($mockUsers); // Pure injection!

Conclusion: Small Change, Big Impact

At first glance, adding a single line to your Application::services() method might seem like a minor update. However, TableContainer represents a significant shift in how we approach CakePHP architecture. By delegating table resolution to the container, we gain:
  • True Type-Safety: Your IDE and static analysis tools now recognize the exact Table class being used. This is a massive win for PHPStan users—no more "Call to an undefined method" errors or messy @var docblock workarounds just to prove to your CI that a method exists.
  • Zero-Effort Mocking: Testing a service no longer requires manipulating the global TableRegistry state. Simply pass a mock object into the constructor and move on.
  • Standardization: Your CakePHP code now aligns with modern PHP practices found in any PSR-compliant ecosystem, making your application more maintainable and easier for new developers to understand.
If you plan to upgrade to CakePHP 5.3 upon its release, this is one of the easiest wins for your codebase. It’s time to stop fetching your tables and start receiving them. This article is part of the CakeDC Advent Calendar 2025 (December 23rd, 2025)

We Bake with CakePHP