CakeDC Blog

TIPS, INSIGHTS AND THE LATEST FROM THE EXPERTS BEHIND CAKEPHP

CakePHP and the Power of Artificial In...

This article is part of the CakeDC Advent Calendar 2025 (December 2th 2025)

Bringing smart automation to modern web development

When we talk about Artificial Intelligence today, we are not talking about the future, we are talking about tools we already use every day, such as our phones, code editors, browsers and productivity apps. For developers, AI represents a new wave of innovation that allows us to embed intelligence directly into our projects to build smarter, more adaptive, and more valuable digital products. At CakeDC, we’ve been exploring how CakePHP 5 can be seamlessly integrated with AI to deliver powerful, automated, and intelligent solutions.

Why combine CakePHP and AI?

Both technologies share a core philosophy: efficiency and structure. CakePHP offers a clean MVC framework, robust validation, and an ORM that keeps your data organized and secure. On the other hand, AI brings reasoning, summarization, and contextual understanding to your application. By combining them, we can:
  • Automate repetitive processes.
  • Enhance user experience.
  • Add value to existing products.
  • Unlock new opportunities for digital innovation.
The result? Smarter apps with a strong core.

What AI means today

AI enhances productivity not by replacing people, but by amplifying human capabilities. It helps analyze data, generate content, automate workflows, and make better decisions faster. And thanks to APIs like OpenAI’s, this power is now accessible to every PHP developer. Imagine a world where your CakePHP app can:
  • Understand natural language input.
  • Summarize uploaded reports.
  • Classify customer feedback.
  • Generate tailored content or recommendations.
That work is already here.

Real use cases with CakePHP + AI

Here are some real examples of how we’re integrating AI into CakePHP projects:
  • Document upload with automatic summaries or data extraction.
  • Customer support chatbots directly embedded in web portals.
  • Image analysis for quality control or content tagging.
  • Smart products or content recommendations.
  • Automated reporting and document generation.
Each of these features leverages the same clean CakePHP architecture (controllers, services, and models) combined with a simple AI API call.

Technical integration made simple

Here’s how easy it is to call an AI model directly from your CakePHP app: use Cake\Http\Client; $http = new \http\Client(); $response = $http->post( 'https://api.openai.com/v1/chat/completions', [ 'model' => 'gpt-4o-mini', 'messages' => [ ['role' => 'system', 'content' => 'You are an assistant.'], ['role' => 'user', 'content' => 'Summarize this text...'], ], ], [ 'headers' => [ 'Authorization' => 'Bearer ' . Configure::Read('OPENAI_API_KEY'), 'Content-Type' => 'application/json', ], ], ); $result = $response->getJson(); From there, you simply parse the JSON response, store or display the data, and integrate it into your workflow. The simplicity of CakePHP’s Http Client makes this process smooth and reliable.

Challenges and best practices

As with any emerging technology, integrating AI comes with responsibilities and considerations:
  • Manage API costs efficiently by batching requests or caching responses.
  • Respect user privacy and comply with GDPR, especially when handling sensitive data.
  • Implement robust error handling and retry logic for API calls.
  • Log and monitor AI interactions for transparency and quality assurance.
  • Use AI responsibly — as a tool to empower developers and users, not to replace them.

Looking ahead

The combination of CakePHP and AI opens exciting possibilities for the next generation of web applications: fast, smart, and secure. AI is not a replacement, it’s an enhancement. And with CakePHP’s solid foundation, developers can bring these intelligent capabilities to life faster than ever. This article is part of the CakeDC Advent Calendar 2025 (December 2th 2025)

The CakeDC Advent Calendar is BACK!

It’s the most wonderful time of the year! I don’t just mean the holidays… I’m talking about the CakeDC Advent Calendar!    If you missed it last year, we put together a series of blog posts in the form of a holiday advent calendar. Each day, you will get to open the gift of a new article written by one of our team members. You can wake up every morning in December with Cake(PHP). Does it get any better?    So what can you expect this year?  Great topics like: 

  • CakePHP upgrades
  • Security tips
  • CakePHP and the power of AI
  • Supabase + CakePHP
  • CakePHP Horizontal Scaling
  • CakePHP and FrankenPHP
  • Advanced Exports in CakePHP 5
  • + so much more! 

  Enjoy our gift to you that lasts the whole month through (maybe I should write poems instead of blogs?).    While you wait, here are some links from last year’s calendar to hold you over: https://www.cakedc.com/yevgeny_tomenko/2024/12/21/cakedc-search-filter-plugin   https://www.cakedc.com/ajibarra/2024/12/12/almost-20-years-a-bit-of-history-about-cakephp   https://www.cakedc.com/jorge_gonzalez/2024/12/20/5-cakephp-security-tips
  See you tomorrow! 

Real-Time Communication Made Simple wi...

This article is part of the CakeDC Advent Calendar 2025 (December 4th 2025) When you're building modern web applications, users expect things to happen instantly. They want to see notifications pop up without refreshing the page, watch live updates flow in, and get immediate feedback when something changes. This is where real-time communication comes into play, and honestly, it used to be a pain to implement. Getting started with the Broadcasting plugin (crustum/broadcasting) is straightforward. Install it via Composer and load it in your application, and you'll have everything you need for real-time communication integrated smoothly with CakePHP 5.x. I remember the old days when we'd write endless JavaScript code to poll the server every few seconds, hoping for new data. It was inefficient, clunky, and made our servers cry under the load. WebSockets changed everything, but they brought their own complexity. You needed separate WebSocket servers, complex connection management, and a whole new way of thinking about client-server communication. That's exactly why I built the CakePHP Broadcasting plugin. I wanted something that felt natural to CakePHP developers, something that didn't require a deep knowledge in WebSocket protocols to understand. The idea was simple: dispatch events on the server, receive them on the client, and let the plugin handle all the messy bits in between.

The Core Idea

Broadcasting in CakePHP works on a beautifully simple concept. You have events happening in your application all the time. A user places an order, someone posts a comment, a file finishes processing. These are just regular events in your CakePHP application. Broadcasting takes these same events and pushes them out to connected clients in real-time. Think about it like a radio station. The station broadcasts a signal, and anyone with a radio tuned to that frequency can hear it. In our case, your CakePHP application broadcasts events, and any client connected to the right channel receives them instantly. No polling, no delays, just pure real-time communication. The plugin supports different broadcast drivers. You can use Pusher Channels if you want a hosted solution that just works. Redis is there for when you want to keep everything on your own infrastructure. You can create your own broadcast driver by implementing the BroadcastDriverInterface.

A Real Example

Let me show you how this works with a practical example. Imagine you're building an e-commerce site, and you want to notify users when their order status changes. They're sitting on the order details page, and boom, a notification appears saying their package has shipped. No page refresh needed. First, you create an event class that implements the BroadcastableInterface. This tells CakePHP that this event should be broadcast to clients. namespace App\Event; use Crustum\Broadcasting\Channel\PrivateChannel; use Crustum\Broadcasting\Event\BroadcastableInterface; class OrderShipped implements BroadcastableInterface { public function __construct( public $order ) {} public function broadcastChannel() { return new PrivateChannel('orders.' . $this->order->id); } public function broadcastEvent(): string { return 'OrderShipped'; } public function broadcastData(): ?array { return [ 'order_id' => $this->order->id, 'tracking_number' => $this->order->tracking_number, 'carrier' => $this->order->carrier, ]; } public function broadcastSocket(): ?string { return null; } } Notice how we're broadcasting to a private channel. Private channels are important when you're dealing with user-specific data. You don't want user A seeing user B's order updates. The channel name includes the order ID, making it unique for each order. Now when something happens in your application, you just broadcast the event. use function Crustum\Broadcasting\broadcast; public function ship($orderId) { $order = $this->Orders->get($orderId); $order->status = 'shipped'; $order->shipped_at = new DateTime(); $this->Orders->save($order); broadcast(new OrderShipped($order)); return $this->redirect(['action' => 'view', $orderId]); } That's it on the server side. The broadcast function takes your event and pushes it out to all connected clients. Behind the scenes, the plugin serializes the data, sends it through your configured broadcast driver, and makes sure it reaches the right channels.

How Data Flows Through Broadcasting

Understanding how your events travel from server to client helps you make better architectural decisions. When you call the broadcast function, your event starts a journey through several layers. Your CakePHP application creates the event object and passes it to the Broadcasting system. The system extracts the channel names, event name, and payload data by calling the methods you defined on your event class. It then hands this data to the configured broadcast driver. If you're using the Pusher driver, the plugin makes an HTTP request to Pusher's API with your event data. Pusher receives this, stores it temporarily, and immediately pushes it to all connected clients who are subscribed to that channel. The clients receive the event through their WebSocket connection and trigger your JavaScript callback. With the Redis driver, the flow is different. Your CakePHP application publishes the event to a Redis pub/sub channel. You need a separate WebSocket server running that subscribes to these Redis channels. When it receives an event from Redis, it broadcasts it to connected WebSocket clients. This gives you full control over your infrastructure but requires running your own WebSocket server. The queue system plays an important role too. By default, broadcasts are queued and processed asynchronously. This means your web request doesn't wait for the broadcast to complete. The broadcast job gets picked up by a queue worker and sent in the background. This keeps your application responsive even when broadcasting to many channels or when the broadcast service has a temporary slowdown.

The Client Side

On the client side, you use Laravel Echo. Yes, it says Laravel in the name, but don't let that fool you. It's just a JavaScript library that knows how to talk to various broadcasting services. It works perfectly with our CakePHP plugin. Setting up Echo is straightforward. You include the library, configure it with your broadcasting service details, and start listening for events. import Echo from 'laravel-echo'; import Pusher from 'pusher-js'; window.Pusher = Pusher; window.Echo = new Echo({ broadcaster: 'pusher', key: 'your-pusher-key', cluster: 'your-cluster', forceTLS: true }); Then you subscribe to your private channel and listen for the OrderShipped event. Echo.private(`orders.${orderId}`) .listen('OrderShipped', (e) => { showNotification('Your order has shipped!'); updateTrackingInfo(e.tracking_number, e.carrier); }); The beauty here is how clean it all is. You're not managing WebSocket connections, handling reconnections, or dealing with message formats. You just say what you want to listen to, and what you want to do when you hear it.

Understanding the Pusher Protocol

The Pusher protocol has become a de facto standard for WebSocket communication. It's not just about Pusher the company anymore. The protocol defines how clients authenticate, subscribe to channels, and receive events in a standardized way. This standardization is actually great news because it means you have options. When a client first connects, it establishes a WebSocket connection and receives a socket ID. This ID uniquely identifies that particular connection. When subscribing to private or presence channels, the client sends this socket ID along with the channel name to your CakePHP application's authorization endpoint. Your server checks if the user can access that channel and returns a signed authentication string. The client then uses this signed string to complete the subscription. The WebSocket server verifies the signature and allows the subscription. This flow ensures that only authorized users can subscribe to private channels, and it all happens transparently through Laravel Echo. The protocol also handles things like connection state, automatic reconnection, and channel member tracking for presence channels. These are complex problems that the protocol solves in a standard way, which is why adopting it makes sense even if you're not using Pusher's hosted service.

Beyond Pusher: Your Own Infrastructure

Here's where things get interesting. You don't have to use Pusher's hosted service. Several open-source projects implement the Pusher protocol, giving you the freedom to run your own WebSocket infrastructure. Soketi is one such project. It's a fast, lightweight WebSocket server written in Node.js that speaks the Pusher protocol. You can run it on your own servers, point your Laravel Echo configuration at it, and everything works exactly the same. Your CakePHP Broadcasting configuration changes slightly to point to your Soketi instance instead of Pusher's servers. 'default' => [ 'className' => 'Crustum/Broadcasting.Pusher', 'key' => 'your-app-key', 'secret' => 'your-app-secret', 'app_id' => 'your-app-id', 'options' => [ 'host' => '127.0.0.1', 'port' => 6001, 'scheme' => 'http', 'useTLS' => false, ], ], On the client side, you configure Echo similarly. window.Echo = new Echo({ broadcaster: 'pusher', key: 'your-app-key', wsHost: '127.0.0.1', wsPort: 6001, forceTLS: false, disableStats: true, }); Soketi integrates with Redis, so you get the benefits of the Redis driver with the simplicity of the Pusher protocol. Your CakePHP app publishes to Redis, Soketi reads from Redis and broadcasts to WebSocket clients. It's a solid architecture that scales well. I'm also working on BlazeCast, a native PHP WebSocket server that implements the Pusher protocol specifically for CakePHP applications. It's designed to feel natural in a CakePHP environment, using familiar concepts and configuration patterns. BlazeCast will integrate deeply with CakePHP. It's currently in development, with plans for an initial release soon. The goal is to provide a zero-configuration WebSocket server that just works with your CakePHP Broadcasting setup.

Redis as a Broadcasting Solution

The Redis driver deserves special attention because it's the most flexible option for self-hosted solutions. When you broadcast an event using the Redis driver, the plugin publishes a message to a Redis pub/sub channel. The message contains all the event data serialized as JSON. 'redis' => [ 'className' => 'Crustum/Broadcasting.Redis', 'connection' => 'default', 'redis' => [ 'host' => '127.0.0.1', 'port' => 6379, 'password' => null, 'database' => 0, ], ], The key advantage of Redis is that it decouples your CakePHP application from the WebSocket server. Your app doesn't need to know or care what's handling the WebSocket connections. It just publishes to Redis and moves on. This separation of concerns makes your architecture more resilient and easier to scale. You can run multiple WebSocket servers all subscribing to the same Redis channels. This gives you horizontal scalability for your WebSocket infrastructure. As your user base grows, you add more WebSocket servers. Your CakePHP application doesn't change at all. Redis pub/sub is also incredibly fast. Publishing a message takes microseconds, so there's virtually no overhead on your application. The WebSocket servers handle all the heavy lifting of maintaining connections and broadcasting to clients. While WebSockets are the most popular approach for real-time communication, it's worth noting that you can implement server-sent events (SSE) as a broadcasting solution as well. An SSE plugin for CakePHP Broadcasting could leverage Redis pub/sub in exactly the same way as WebSocket-based drivers. In this model, your application would publish event data to Redis channels, and a separate PHP process (or worker) would stream those events to connected clients over HTTP using SSE. This approach is ideal for applications where you only need one-way communication from server to client, doesn't require extra JavaScript libraries, and works natively in all modern browsers. By utilizing Redis as the backbone for message distribution, CakePHP could offer an SSE broadcasting driver that's simple, reliable, and well-suited for many real-time dashboard and notification use cases. This is an exciting possibility for future plugin development. The downside is you need to run a WebSocket server. Soketi works well for this, as does Laravel's Echo Server or the upcoming BlazeCast. You're trading the simplicity of a hosted solution for complete control over your infrastructure.

Authorization and Security

Private channels need authorization. When a user tries to subscribe to a private channel, Echo makes a request to your CakePHP application asking "can this user listen to this channel?" You define that logic in your channels configuration file. use Crustum\Broadcasting\Broadcasting; use Cake\ORM\TableRegistry; Broadcasting::channel('private-orders.{orderId}', function ($user, $orderId) { $ordersTable = TableRegistry::getTableLocator()->get('Orders'); $order = $ordersTable->get($orderId); return $user->id === $order->user_id; }); This simple function checks if the authenticated user owns the order they're trying to listen to. If they do, authorization succeeds and they can receive updates. If not, the subscription is rejected. Security sorted. The authorization flow is interesting when you understand what's actually happening. When Echo calls your authorization endpoint, it sends the socket ID, channel name, and your application's authentication cookies or tokens. Your CakePHP application verifies the user is logged in using your normal authentication system. If the user is authorized, your application generates a signature using your broadcast secret key, the channel name, and the socket ID. This signature proves that your server authorized this specific socket to subscribe to this specific channel. The client sends this signature to the WebSocket server, which verifies it using the same secret key. This is important for the Pusher protocol. Whether you're using hosted Pusher, Soketi, or BlazeCast, they all work the same way. Your CakePHP application is the source of truth for who can access what. The WebSocket server just enforces the authorizations your application provides. This keeps your security logic centralized and makes it easy to update authorization rules without touching the WebSocket infrastructure. sequenceDiagram participant Client participant WebSocket Server participant CakePHP (HTTP Server) Client->>WebSocket Server: WebSocket Connection (wss://) WebSocket Server->>Client: HTTP 101 Switching Protocols Note over Client,CakePHP: For private/presence channels: Client->>CakePHP: POST /broadcasting/auth (with auth headers) CakePHP->>CakePHP: Verify session/channel permissions alt Authenticated CakePHP->>Client: 200 OK (with auth token) Client->>WebSocket Server: Subscribe with auth token WebSocket Server->>CakePHP: Verify token validity CakePHP->>WebSocket Server: Auth confirmation WebSocket Server->>Client: "pusher:subscription_succeeded" else Not Authenticated CakePHP->>Client: 403 Forbidden Client->>WebSocket Server: (No subscription attempt) end

Presence Channels

Here's where things get really interesting. Presence channels not only broadcast events but also keep track of who's subscribed to them. This is perfect for features like "who's online" lists, real-time collaboration indicators, or live chat systems. When someone joins a presence channel, everyone else already subscribed gets notified. When they leave, same thing. You even get a list of all currently subscribed users when you first join. Echo.join(`chat.${roomId}`) .here((users) => { console.log('Currently in the room:', users); }) .joining((user) => { console.log(user.name + ' just joined'); }) .leaving((user) => { console.log(user.name + ' just left'); }); The server-side authorization for presence channels returns user data instead of just true or false. Broadcasting::channel('presence-chat.{roomId}', function ($user, $roomId) { if ($user->canJoinRoom($roomId)) { return [ 'id' => $user->id, 'name' => $user->name, 'avatar' => $user->avatar_url ]; } }); This data gets shared with all participants, so they know who they're chatting with. It's incredibly useful for building social features.

Testing Your Broadcasts

Testing real-time features used to be a nightmare. How do you verify something was broadcast? How do you check the right data was sent to the right channels? The plugin includes a testing trait that makes this straightforward. use Crustum\Broadcasting\TestSuite\BroadcastingTrait; class OrderTest extends TestCase { use BroadcastingTrait; public function testOrderShippedBroadcast() { $order = $this->Orders->get(1); $order->status = 'shipped'; $this->Orders->save($order); $this->assertBroadcastSent('OrderShipped'); $this->assertBroadcastSentToChannel('orders.1', 'OrderShipped'); $this->assertBroadcastPayloadContains('OrderShipped', 'tracking_number', 'ABC123'); } } The trait captures all broadcasts during your test instead of actually sending them. Then you can make assertions about what would have been broadcast. It's fast, reliable, and lets you test your broadcasting logic without needing actual WebSocket connections or external services. There are many diferent assertions you can use to test your broadcasts. You can assert that the right broadcasts were sent to the right channels. You can even inspect the broadcast data to verify it contains the correct information.

Wrapping Up

Building real-time features doesn't have to be complicated. The CakePHP Broadcasting plugin takes all the complexity of WebSocket management, connection handling, and message routing, and hides it behind a simple, CakePHP-friendly interface. You dispatch events like you always do. The plugin broadcasts them. Clients receive them. Everything just works, and you can focus on building features instead of fighting with infrastructure. Whether you're building a notification system, a live dashboard, a chat application, or anything else that needs real-time updates, broadcasting has you covered. It's the missing piece that makes modern, responsive web applications feel natural in CakePHP. Try it out in your next project. I think you'll find that once you start broadcasting events, you'll wonder how you ever built real-time features without it. This article is part of the CakeDC Advent Calendar 2025 (December 4th 2025)

Understanding Your Application's Heart...

This article is part of the CakeDC Advent Calendar 2025 (December 3th 2025) Every application has a heartbeat. CPU usage spikes, database queries slow down, memory fills up, queues back up. These things happen all the time, but we usually only notice them when something breaks. By then, it's too late. You're in firefighting mode, users are complaining, and you're digging through logs trying to figure out what went wrong. I built Rhythm because I was tired of this reactive approach. I wanted to know what my applications were doing before they fell over. I wanted to catch slow queries before they became a problem. I wanted to see which endpoints were struggling before users started reporting errors. Basically, I wanted to understand my application's heartbeat. Installing Rhythm (skie/rhythm) takes just one Composer command. After running the migrations to create the necessary database tables, you're ready to start monitoring your application. To ensure continuous monitoring and accurate, up-to-date metrics, you'll want to run the Rhythm digest and check commands on a schedule. These commands are designed to be triggered by a cron job or as persistent background services. The rhythm check command collects server and system metrics periodically, while rhythm digest processes and aggregates ingested metrics for your dashboards and widgets. Setting them up as recurring tasks means your monitoring remains real-time and accurate, even as your load changes. For most installations, adding them to your server's crontab (or running as a systemd service) is all it takes to get ongoing insight into application performance with zero manual intervention.

The Philosophy Behind Rhythm

Most monitoring tools fall into two camps. Either they're incredibly simple and only show you basic metrics, or they're enterprise monsters that require a dedicated team to configure and maintain. I wanted something in between. Something that would give me real insights without drowning me in complexity. Rhythm is designed around a simple idea: capture what matters, aggregate it efficiently, and show it in a way that makes sense. It doesn't try to do everything. It focuses on the metrics that actually help you understand your application's performance and health. The plugin automatically monitors servers, tracks HTTP requests, catches slow database queries, monitors your queue system, logs exceptions, and watches background jobs. All of this happens automatically once you install it. No complex configuration, no manual instrumentation of every single endpoint.

How It Actually Works

Rhythm uses a two-phase approach that solves a fundamental problem with PHP monitoring. PHP runs and dies. A request comes in, PHP processes it, sends a response, and terminates. This makes it tricky to collect and aggregate metrics because there's no long-running process to accumulate data. The ingest phase happens during your normal request cycle. Rhythm collects metrics in memory as your application runs. When the request finishes, it dumps these metrics into a persistent queue, either in your database or in Redis. This is fast and doesn't slow down your responses. The digest phase processes the queued metrics into final storage with automatic aggregation. This happens separately, either as a scheduled job or a daemon process. It takes all those individual metric points and aggregates them into useful summaries. How many requests per minute? What was the average response time? Which queries were slowest? This separation means your application stays fast while still collecting detailed performance data. The heavy lifting happens outside the request cycle, so users never feel the monitoring overhead.

The Dashboard Experience

The Rhythm dashboard lives at the rhythm route in your application. When you open it, you immediately see what's happening across your infrastructure. Server performance metrics show CPU, memory, and disk usage for all your servers. Queue statistics show how many jobs are waiting, processing, and failing. Recent exceptions appear with their frequency and locations. What makes this useful is the real-time aspect. The dashboard updates live as new data arrives. You can watch your application breathe. You see patterns emerge. That endpoint that spikes CPU every hour? You'll spot it. The query that only becomes slow under certain conditions? It'll show up. The dashboard is built with CakePHP's view system, so customizing it feels natural. Want to add a custom widget? Create a class that extends BaseWidget, write a template, and drop it in the dashboard layout. Want to change the layout? Just edit the template file. No JavaScript rebuilds, no complex build processes.

Monitoring What Matters

The servers recorder captures system-level metrics. It monitors CPU usage, memory consumption, and disk space across all your servers. This runs as a background daemon using the rhythm check command, taking snapshots at regular intervals. If you run multiple servers, each one reports its own metrics, and you see them all in the dashboard. Server State Widget Request monitoring happens automatically. Every HTTP request gets logged with its response time, memory usage, and status code. Slow requests above your configured threshold get flagged for special attention. You can see which endpoints are struggling, which users are making the most requests, and where your performance bottlenecks are. Slow Requests Widget Database monitoring watches your queries. Any query that exceeds your threshold gets logged with its SQL, execution time, and location in your code. This is incredibly useful for finding N+1 queries or identifying queries that only become slow under certain data conditions. The slow queries widget groups similar queries together so you can see patterns rather than drowning in individual slow query instances. Slow Queries Widget Queue monitoring integrates with CakePHP's queue system. It tracks how many jobs are queued, how many are processing, how many succeeded, and how many failed. The queues widget shows this as a time series, so you can see if your queues are backing up or if your workers are keeping up with the load. Queues Widget Exception tracking captures every exception your application throws. It groups them by exception class and location, showing you which errors are most common and which are most recent. This gives you a quick overview of what's going wrong in your application without diving through log files. Exceptions Widget

The Git Widget

One feature I find myself using constantly is the Git widget. It shows your current branch, recent commits, and repository status right in the dashboard. This seems simple, but it's incredibly useful when you're troubleshooting. You deploy a change, and suddenly exceptions start appearing. Was that commit really deployed? What branch is production running? What were the recent changes? The Git widget answers these questions immediately. No need to SSH into servers or check deployment logs. The widget automatically categorizes commits as features, fixes, docs, tests, or other based on the commit message. It extracts ticket numbers and merge information. It even shows tags and version numbers. All the context you need to understand what code is actually running. Git Status Widget

Redis and Database Monitoring

For applications that rely heavily on Redis or specific databases, Rhythm includes specialized monitors. The Redis monitor tracks memory usage, key statistics, evictions, and network traffic. The MySQL monitor watches connections, InnoDB metrics, and query performance. The PostgreSQL monitor tracks similar database-specific metrics. These monitors run on an interval, capturing snapshots of your database or cache server's health. You see trends over time. Is Redis memory usage growing? Are database connections maxing out? These are the kinds of problems you want to catch before they cause outages.

Application Info

The App Info widget displays comprehensive application information including CakePHP version, PHP version, debug mode status, system configuration, and database information. This widget provides a quick overview of your application's environment and configuration. App Info Widget

Performance Considerations

Monitoring adds overhead. There's no way around it. But Rhythm is designed to make that overhead negligible. The sampling system lets you monitor only a fraction of events while still getting accurate insights. If you're getting 10,000 requests per minute, you probably don't need to record every single one. Sample 10% of them, and Rhythm automatically scales the numbers in the dashboard. The aggregation system is efficient because it pre-computes summaries during the digest phase. When you load the dashboard, it's not calculating averages or counting records on the fly. It's reading pre-computed aggregates that update periodically. This makes the dashboard fast even with millions of metric data points. Using Redis for ingestion is faster than writing to the database during every request. The metrics get queued in Redis, and the digest process pulls them out in batches. If you're on a shared database or your database is already under load, Redis ingestion can make a real difference.

The Null Driver

During testing or development, you might not want any monitoring overhead at all. The null ingest driver discards all metrics silently. You can leave all your Rhythm calls in place, but nothing gets recorded. This is perfect for running tests quickly or for development environments where you don't care about metrics. Switch back to the database or Redis driver in production, and all those metrics start flowing again. No code changes needed.

Real Problems, Real Solutions

I've used Rhythm to solve real problems in production applications. One application was experiencing random slowdowns. The slow requests widget showed they were all hitting the same endpoint. The slow queries widget revealed that endpoint had a query that only became slow when a certain condition was true. Fixed the query, problem solved. Another application was running out of disk space on its database server. The server state widget showed disk usage climbing steadily. Investigation revealed old Rhythm entries weren't being trimmed properly. Configured the trim settings correctly, and the problem stopped. A third application had intermittent Redis memory issues. The Redis monitor showed periodic spikes in memory usage corresponding to background jobs running. Turned out some jobs were caching too much data. Adjusted the caching strategy, and Redis stabilized. These aren't dramatic stories, but that's the point. Rhythm helps you catch and fix problems before they become dramatic. It gives you visibility into what your application is actually doing, and that visibility makes debugging so much easier.

Configuration and Setup

Getting Rhythm running is straightforward. Install the plugin via Composer, run the migrations to create the database tables, and load the plugin in your Application class. That's the minimum setup. The configuration file lets you enable or disable specific recorders, adjust sampling rates, and configure storage options. Most of the defaults are sensible, so you can start with minimal configuration and adjust as you learn what metrics matter for your application. If you want to monitor multiple servers, run the rhythm check command on each one. If you're using Redis ingestion, run the rhythm digest command to process queued metrics. Both of these are long-running commands meant to run as daemons, so you'll want to use Supervisor or systemd to keep them running.

Closing Thoughts

Monitoring shouldn't be complicated. You shouldn't need to be a DevOps expert to understand what your application is doing. You shouldn't need to set up complex infrastructure just to know if your servers are healthy. Rhythm gives you that visibility with minimal setup and maintenance. It captures the metrics that matter, shows them in a dashboard that makes sense, and stays out of your way. It's monitoring that feels natural in CakePHP, using conventions you already know. Install it, glance at the dashboard occasionally, and you'll develop an intuition for what normal looks like in your application. When something changes, you'll notice. When something breaks, you'll have the data to understand why. That's what monitoring should be. Your application has a heartbeat. Rhythm helps you hear it. This article is part of the CakeDC Advent Calendar 2025 (December 3th 2025)

CakePHP AI Integration: Build a CakePH...

Learn how to build a CakePHP MCP server (local) for AI integration.

Intro

Unless your crew left you stranded on a desert island earlier this year, I'm sure you've heard about every big name in the industry integrating their applications and exposing their data to "agents". Model Context Protocol https://modelcontextprotocol.io/docs/getting-started/intro was created to define how the your application could interact and provide features to Agents. These features could be readonly, but also methods (or tools) to allow the Agent operate with your application, for example, creating orders, updating post titles, reordering invoices, or creating reports for your bookings. As a developer, this is a quick win! Providing access, even readonly, could expand the quality of the interaction between your users and your application. In my opinion, the benefits are: Agents deal with context very well, they can use the conversation history, and also extract required data to use the available tools. Agents can transform the data, providing "features" to your users that you didn't implement. For example building charts on the fly, or creating scripts to transform the data for another tool. Quickly after the publication of the MCP protocol, the PHP community started working on a standarized SDK to help with the implementation of MCP servers. Even if the SDK is in active development right now, we are going to explore it and build a local MCP server, connecting Claude Desktop to it. The idea behind the example is to open your application to Claude Desktop, so the Agent (Claude) can connect directly to your code using the specified tools. For production environments, there are many other considerations we should be handling, like authorization, rate limiting, data exchange and privacy, etc. We'll leave all these production grade issues for another day and jump into an example you can implement "today" in your CakePHP application. Development vs. Production: This tutorial focuses on a local development setup for your CakePHP MCP server. Production environments require additional considerations including:
  • Authentication and authorization
  • Rate limiting
  • Data privacy and security
  • Audit logging
  • Input validation and sanitization
  • Error handling and monitoring

What is a CakePHP MCP Server?

A CakePHP MCP server is a specialized implementation that allows AI agents like Claude to interact with your CakePHP application through the Model Context Protocol. This CakePHP AI integration creates a bridge between your application logic and AI capabilities, enabling:
  • Natural language interfaces for complex queries
  • Automated content generation and management
  • Real-time data analysis and reporting

Prerequisites

Before starting, ensure you have:
  • PHP 8.1 or higher
  • Composer
  • SQLite or MySQL
  • Claude Desktop (free tier available)

Step 1: Set Up the CakePHP CMS Application

We'll use the official CakePHP CMS tutorial. # Clone the repository git clone https://github.com/cakephp/cms-tutorial cd cms-tutorial # Install dependencies composer install # Run database migrations bin/cake migrations migrate # Start the development server bin/cake server

Create a Test User

  1. Navigate to http://localhost:8765/users/add
  2. Create a new user with your preferred email and password
  3. Log in at http://localhost:8765/users/login
  4. Verify you can create an article via http://localhost:8765/articles/add

Step 2: Install Claude Desktop

Download and install Claude Desktop from https://claude.com/download

Step 3: Install the CakePHP MCP Plugin

To build your CakePHP MCP server, install the MCP utility plugin and SDK in your CakePHP project: composer require cakedc/cakephp-mcp:dev-2.next-cake5 mcp/sdk:'dev-main#4b91567' Note: These packages are in active development.

Step 4: Create the CakePHP MCP Server Script

Create a new file bin/mcp to initialize your CakePHP MCP server: #!/usr/bin/env sh cd /absolute/path/to/your/cms-tutorial && php vendor/cakedc/cakephp-mcp/bin/mcp-server Important: Replace /absolute/path/to/your/cms-tutorial with your actual project path. For example: /home/user/cms-tutorial or C:\Users\YourName\cms-tutorial Make the script executable: chmod +x bin/mcp

Step 5: Create Your First CakePHP MCP Tool

Create the file: src/Mcp/Articles.php <?php namespace App\Mcp; use App\Model\Entity\Article; use Cake\ORM\Locator\LocatorAwareTrait; use Mcp\Capability\Attribute\McpTool; class Articles { use LocatorAwareTrait; #[McpTool(name: 'createArticle')] public function createArticle(string $title, string $body): array { try { $article = new Article([ 'title' => $title, 'body' => $body, 'user_id' => $this->fetchTable('Users')->find()->firstOrFail()->id, // a default user ID for simplicity ]); if (!$this->fetchTable('Articles')->save($article)) { return [ 'success' => false, 'message' => 'Failed to create article: ' . json_encode($article->getErrors()), ]; } return [ 'success' => true, 'message' => 'Article created successfully', ]; } catch (\Throwable $e) { return [ 'success' => false, 'message' => 'Exception to create article: ' . $e->getMessage(), ]; } } } The #[McpTool] Attribute: This PHP 8 attribute registers the method as an MCP tool that Claude can discover and use in your CakePHP AI integration. The name parameter defines how Claude will reference this tool. Simplified User Assignment: For demonstration purposes, we're using the first available user. In production CakePHP AI integrations, you'd implement proper user authentication and context.

Step 6: Configure Claude Desktop for CakePHP MCP Integration

Add your CakePHP MCP server to Claude Desktop's configuration:
  1. Open Claude Desktop
  2. Go to Settings → Developer → Edit Config
  3. Add your MCP server configuration:
{ "mcpServers": { "cakephp-cms": { "command": "/absolute/path/to/your/cms-tutorial/bin/mcp" } } }
  1. Save the configuration and restart Claude Desktop

Step 7: Test Your CakePHP AI Integration

Once Claude Desktop restarts, you should see your CakePHP MCP server connected:
  1. MCP Server Connected: Look for the server indicator in Claude Desktop showing your CakePHP MCP integration is active
  2. Available Tools: You can view available CakePHP MCP tools by clicking the tools icon
  3. The createArticle tool: Should appear in the list of available tools
  4. Now you can use the Claude Desktop prompt to generate articles, that will be saved directly into your CakePHP application!

Wrapping up

You've successfully built a CakePHP MCP server and implemented CakePHP AI integration with Claude! This Model Context Protocol CakePHP implementation opens up powerful possibilities for AI-enhanced user experiences and automation in your web applications.

CakeFest 2025 Wrap Up

For years I have heard the team talk about Madrid being one of their favorite cities to visit, because they hosted CakeFest there more than a decade ago. I can now confirm… they were right! What a beautiful city. Another great CakeFest in the books… Thanks Madrid!   Not only are we coming down from the sugar high, but we are also honored to be celebrating 20 years of CakePHP. It was amazing to celebrate with the attendees (both physical and virtual). If you watched the cake ceremony, you saw just how emotional it made Larry to reminisce on the last 20 years. I do know one thing, CakePHP would not be where it is without the dedicated core, and community.    Speaking of the core, we had both Mark Scherer and Mark Story joining us as presenters this year. It is a highlight for our team to interact with them each year. I know a lot of the other members from the core team would have liked to join us as well, but we hope to see them soon. The hard work they put in day after day is unmatched, and often not recognized enough. It’s hard to put into words how grateful we are for this group of bakers.    Our event was 2 jam packed days of workshops and talk presentations, which you can now see a replay of on our YouTube channel (youtube.com/cakephp). We had presenters from Canada, Germany, India, Spain, USA, and more! This is one of my favorite parts about the CakePHP community, the diversity and representation from all over the world. When we come together in one room, with one common goal, it’s just magical. Aside from the conference itself, the attendees had a chance to network, mingle, and enjoy meals together as a group.  I could sense the excitement of what’s to come for a framework that is very much still alive. Speaking of which… spoiler alert: CakePHP 6 is coming. Check out the roadmap HERE.   I feel as though our team leaves the event each year with a smile on their face, and looking forward to the next. The events are growing each year, although we do like to keep the small group/intimate type of atmosphere. I am already getting messages about the location for next year, and I promise we will let you know as soon as we can (when we know!). In the meantime, start preparing your talks, and send us your location votes.   The ovens are heating up….

Polymorphic Relationships in CakePHP: ...

Have you ever wondered how to make one database table relate to multiple other tables? Imagine a comments table that needs to store comments for both articles and videos. How do you manage that without creating separate tables or complicated joins? The answer is a polymorphic relationship. It sounds fancy, but the idea is simple and super powerful.

What's a Polymorphic Relationship?

Think of it this way: instead of a single foreign key pointing to one specific table, a polymorphic relationship uses two columns to define the connection. Let's stick with our comments example. To link a comment to either an article or a video, your comments table would have these two special columns:
  1. foreign_id: This holds the ID of the related record (e.g., the id of an article or the id of a video).
  2. model_name: This stores the name of the model the comment belongs to (e.g., 'Articles' or 'Videos').
This flexible setup allows a single comment record to "morph" its relationship, pointing to different types of parent models. It's clean, efficient, and saves you from a lot of redundant code. It's not necessary for them to be called "foreign_id" and "model_name"; they could have other names (table, model, reference_key, model_id, etc.) as long as you maintain the intended function of each. Now, let's see how you can set this up in CakePHP 5 without breaking a sweat.

Making It Work in CakePHP 5

While some frameworks have built-in support for polymorphic relationships, CakePHP lets you create them just as easily using its powerful ORM (Object-Relational Mapper) associations. We'll use the conditions key to define the polymorphic link.

Step 1: Set Up Your Database

We'll use a simple schema with three tables: articles, videos, and comments. -- articles table CREATE TABLE articles ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255) ); -- videos table CREATE TABLE videos ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255) ); -- comments table CREATE TABLE comments ( id INT AUTO_INCREMENT PRIMARY KEY, content TEXT, foreign_id INT NOT NULL, model_name VARCHAR(50) NOT NULL ); Notice how the comments table has our special foreign_id and model_name columns.

Step 2: Configure Your Models in CakePHP

Now for the magic! We'll define the associations in our Table classes. ArticlesTable.php In this file, you'll tell the Articles model that it has many Comments, but with a specific condition. // src/Model/Table/ArticlesTable.php namespace App\Model\Table; use Cake\ORM\Table; class ArticlesTable extends Table { public function initialize(array $config): void { // ... $this->hasMany('Comments', [ 'foreignKey' => 'foreign_id', 'conditions' => ['Comments.model_name' => self::class], // or 'Articles' 'dependent' => true, // Deletes comments if an article is deleted ]); } } Use self::class is a best practice in modern PHP, as it prevents bugs if you ever decide to rename your classes, and your IDE can auto-complete and check it for you VideosTable.php You'll do the same thing for the Videos model, but change the model_name condition. // src/Model/Table/VideosTable.php namespace App\Model\Table; use Cake\ORM\Table; class VideosTable extends Table { public function initialize(array $config): void { // ... $this->hasMany('Comments', [ 'foreignKey' => 'foreign_id', 'conditions' => ['Comments.model_name' => self::class], // or 'Videos' 'dependent' => true, ]); } } CommentsTable.php This table is the owner of the polymorphic association. You can add associations here to easily access the related Article or Video from a Comment entity. // src/Model/Table/CommentsTable.php namespace App\Model\Table; use Cake\ORM\Table; class CommentsTable extends Table { public function initialize(array $config): void { // ... $this->belongsTo('Articles', [ 'foreignKey' => 'foreign_id', 'conditions' => ['Comments.model_name' => \App\Model\Table\ArticlesTable::class], // or 'Articles' ]); $this->belongsTo('Videos', [ 'foreignKey' => 'foreign_id', 'conditions' => ['Comments.model_name' => \App\Model\Table\VideosTable::class], // or 'Videos' ]); } }

Step 3: Using the Relationship

Now that everything is set up, you can fetch data as if it were a normal association. Fetching Comments for an Article: $article = $this->Articles->get(1, ['contain' => 'Comments']); // $article->comments will contain a list of comments for that article Creating a new Comment for a Video: $video = $this->Videos->get(2); $comment = $this->Comments->newEmptyEntity(); $comment->content = 'This is an awesome video!'; $comment->foreign_id = $video->id; $comment->model_name = \App\Model\Table\VideosTable::class; // or 'Videos' $this->Comments->save($comment); As you can see, the model_name and foreign_id fields are the secret sauce that makes this pattern work.

What About the Future? The Power of This Solution

Now that you've got comments working for both articles and videos, what if your app grows and you want to add comments to a new model, like Photos? With this polymorphic setup, the change is incredibly simple. You don't need to alter your comments table at all. All you have to do is: Create your photos table in the database. Add a new PhotosTable.php model. In the new PhotosTable's initialize() method, add the hasMany association, just like you did for Articles and Videos. // src/Model/Table/PhotosTable.php namespace App\Model\Table; use Cake\ORM\Table; class PhotosTable extends Table { public function initialize(array $config): void { // ... $this->hasMany('Comments', [ 'foreignKey' => 'foreign_id', 'conditions' => ['Comments.model_name' => self::class], 'dependent' => true, ]); } } That's it! You've just extended your application's functionality with minimal effort. This demonstrates the true power of polymorphic relationships: a single, scalable solution that can easily adapt to your application's evolving needs. It's a key pattern for building flexible and maintainable software.

Conclusion

This approach is flexible, scalable, and a great way to keep your database schema simple. Now that you know the basics, you can start applying this pattern to more complex problems in your own CakePHP applications!

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 Functi...

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)

CakeDC Search Filter Plugin

This article is part of the CakeDC Advent Calendar 2024 (December 21th 2024) The CakeDC Search Filter plugin is a powerful tool for CakePHP applications that provides advanced search functionality with a modern, user-friendly interface. It combines backend flexibility with a Vue.js-powered frontend to create dynamic search filters. Key features include:

  • Dynamic filter generation based on database schema
  • Multiple filter types for different data types
  • Customizable search conditions
  • Interactive Vue.js frontend
  • AJAX-powered autocomplete functionality
  • Seamless integration with CakePHP's ORM

Setup

  1. Install the plugin using Composer: composer require cakedc/search-filter
  2. Load the plugin in your application's src/Application.php: $this->addPlugin('CakeDC/SearchFilter');
  3. Add the search element to your view inside search form: <?= $this->element('CakeDC/SearchFilter.Search/v_search'); ?>
  4. Initialize the Vue.js application: <script> window._search.createMyApp(window._search.rootElemId) </script>

Filters

Filters are the user interface elements that allow users to interact with the search. The plugin provides several built-in filter types for different data scenarios:
  1. BooleanFilter: For Yes/No selections $booleanFilter = (new BooleanFilter()) ->setCriterion(new BoolCriterion('is_active')) ->setLabel('Active Status') ->setOptions([1 => 'Active', 0 => 'Inactive']);
  2. DateFilter: For date-based filtering $dateFilter = (new DateFilter()) ->setCriterion(new DateCriterion('created_date')) ->setLabel('Creation Date') ->setDateFormat('YYYY-MM-DD');
  3. StringFilter: For text-based searches $stringFilter = (new StringFilter()) ->setCriterion(new StringCriterion('title')) ->setLabel('Title');
  4. NumericFilter: For number-based filtering $numericFilter = (new NumericFilter()) ->setCriterion(new NumericCriterion('price')) ->setLabel('Price') ->setProperty('step', '0.01');
  5. LookupFilter: For autocomplete-based filtering $lookupFilter = (new LookupFilter()) ->setCriterion(new LookupCriterion('user_id', $usersTable, new StringCriterion('name'))) ->setLabel('User') ->setLookupFields(['name', 'email']) ->setAutocompleteRoute(['controller' => 'Users', 'action' => 'autocomplete']);
  6. MultipleFilter: For selecting multiple values $multipleFilter = (new MultipleFilter()) ->setCriterion(new InCriterion('category_id', $categoriesTable, new StringCriterion('name'))) ->setLabel('Categories') ->setProperty('placeholder', 'Select multiple options');
  7. SelectFilter: For dropdown selections $selectFilter = (new SelectFilter()) ->setCriterion($manager->criterion()->numeric('status_id')) ->setLabel('Status') ->setOptions($this->Statuses->find('list')->toArray()) ->setEmpty('All Statuses');

Criteria Purpose and Usage

Criteria are the building blocks that define how filters operate on your data. They handle the actual query building and data filtering. Key criterion types include:
  1. AndCriterion: Combines multiple criteria with AND logic
  2. BoolCriterion: Handles boolean comparisons
  3. StringCriterion: Handles string matching
  4. DateCriterion: Manages date comparisons
  5. DateTimeCriterion: Manages datetime comparisons
  6. InCriterion: Handles in comparisons
  7. LookupCriterion: Handles lookup comparisons
  8. NumericCriterion: Handles numeric comparisons
  9. OrCriterion: Combines multiple criteria with OR logic
Example of combining criteria: $complexCriterion = new OrCriterion([ new StringCriterion('title'), new StringCriterion('content') ]);

Filters Usage

Let's walk through a complete example of setting up filters in a controller. This implementation demonstrates how to integrate search filters with our htmx application from previous articles. Figure 1-1

Controller Setup

First, we need to initialize the PlumSearch filter component in our controller: public function initialize(): void { parent::initialize(); $this->loadComponent('PlumSearch.Filter'); }

Implementing Search Filters

Here's a complete example of setting up filters in the controller's list method: // /src/Controller/PostsController.php protected function list() { $query = $this->Posts->find(); $manager = new Manager($this->request); $collection = $manager->newCollection(); $collection->add('search', $manager->filters() ->new('string') ->setConditions(new \stdClass()) ->setLabel('Search...') ); $collection->add('name', $manager->filters() ->new('string') ->setLabel('Name') ->setCriterion( new OrCriterion([ $manager->buildCriterion('title', 'string', $this->Posts), $manager->buildCriterion('body', 'string', $this->Posts), ]) ) ); $collection->add('created', $manager->filters() ->new('datetime') ->setLabel('Created') ->setCriterion($manager->buildCriterion('created', 'datetime', $this->Posts)) ); $viewFields = $collection->getViewConfig(); if (!empty($this->getRequest()->getQuery()) && !empty($this->getRequest()->getQuery('f'))) { $search = $manager->formatSearchData(); $this->set('values', $search); $this->Posts->addFilter('search', [ 'className' => 'Multiple', 'fields' => [ 'title', 'body', ] ]); $this->Posts->addFilter('multiple', [ 'className' => 'CakeDC/SearchFilter.Criteria', 'criteria' => $collection->getCriteria(), ]); $filters = $manager->formatFinders($search); $query = $query->find('filters', params: $filters); } $this->set('viewFields', $viewFields); $posts = $this->paginate($this->Filter->prg($query), ['limit' => 12]); $this->set(compact('posts')); }

Table Configuration

Enable the filterable behavior in your table class: // /src/Model/Table/PostsTable.php public function initialize(array $config): void { // ... $this->addBehavior('PlumSearch.Filterable'); }

View Implementation

In your view template, add the necessary assets and initialize the search filter: <!-- templates/Posts/index.php --> <?= $this->Html->css('CakeDC/SearchFilter.inline'); ?> <?= $this->Html->script('CakeDC/SearchFilter.vue3.js'); ?> <?= $this->Html->script('CakeDC/SearchFilter.main.js', ['type' => 'module']); ?> <?= $this->element('CakeDC/SearchFilter.Search/v_templates'); ?> <div id="search"> <?= $this->Form->create(null, [ 'id' => 'search-form', 'type' => 'get', 'hx-get' => $this->Url->build(['controller' => 'Posts', 'action' => 'index']), 'hx-target' => "#posts", ]); ?> <div id="ext-search"></div> <?= $this->Form->button('Search', ['type' => 'submit', 'class' => 'btn btn-primary']); ?> <?= $this->Form->end(); ?> </div> <script> window._search = window._search || {}; window._search.fields = <?= json_encode($viewFields) ?>; var values = null; <?php if (!empty($values)): ?> window._search.values = <?= json_encode($values) ?>; <?php else: ?> window._search.values = {}; <?php endif; ?> </script>

JavaScript Integration

Finally, add the necessary JavaScript to handle the search filter initialization and htmx interactions: <!-- /templates/Posts/index.php --> <script> function setupTable(reload) { if (reload) { setTimeout(function () { window._search.app.unmount() window._search.createMyApp(window._search.rootElemId) }, 20); } } document.addEventListener('DOMContentLoaded', function() { window._search.createMyApp(window._search.rootElemId) setupTable(false); htmx.on('htmx:afterRequest', (evt) => { setupTable(true); }) }); </script> The combination of CakePHP's search filter plugin with htmx provides a modern, responsive search experience with minimal JavaScript code.

Frontend Vue App Widgets

The plugin provides several Vue.js widgets for different filter types:
  • SearchInput: For basic text input
  • SearchInputNumericRange: For basic text input
  • SearchSelect, Select2, SearchSelectMultiple: For dropdown selections
  • SearchInputDate, SearchInputDateRange: For date picking
  • SearchInputDateTime, SearchInputDateTimeRange: For datetime picking
  • SearchLookupInput: For autocomplete functionality
  • SearchMultiple: For multiple selections
  • SearchSelectMultiple: For multiple selections
These widgets are automatically selected based on the filter type you define in your controller.

Custom Filters and Custom Widgets

The CakeDC Search Filter plugin can be extended with custom filters and widgets. Let's walk through creating a custom range filter that allows users to search between two numeric values.

Custom Filter Class

First, create a custom filter class that extends the AbstractFilter: // /src/Controller/Filter/RangeFilter.php <?php declare(strict_types=1); namespace App\Controller\Filter; use Cake\Controller\Controller; use CakeDC\SearchFilter\Filter\AbstractFilter; class RangeFilter extends AbstractFilter { protected array $properties = [ 'type' => 'range', ]; protected object|array|null $conditions = [ self::COND_BETWEEN => 'Between', ]; }

Custom Criterion Implementation

Create a criterion class to handle the range filtering logic: // /src/Model/Filter/Criterion/RangeCriterion.php <?php declare(strict_types=1); namespace App\Model\Filter\Criterion; use Cake\Database\Expression\QueryExpression; use Cake\ORM\Query; use CakeDC\SearchFilter\Filter\AbstractFilter; use CakeDC\SearchFilter\Model\Filter\Criterion\BaseCriterion; class RangeCriterion extends BaseCriterion { protected $field; public function __construct($field) { $this->field = $field; } public function __invoke(Query $query, string $condition, array $values, array $criteria, array $options): Query { $filter = $this->buildFilter($condition, $values, $criteria, $options); if (!empty($filter)) { return $query->where($filter); } return $query; } public function buildFilter(string $condition, array $values, array $criteria, array $options = []): ?callable { return function (QueryExpression $exp) use ($values) { if (!empty($values['from']) && !empty($values['to'])) { return $exp->between($this->field, $values['from'], $values['to']); } return $exp; }; } public function isApplicable($value, string $condition): bool { return !empty($value['from']) || !empty($value['to']); } }

Controller Integration

Update your controller to use the custom range filter: // /src/Controller/PostsController.php protected function list() { // ... $manager = new Manager($this->request); $manager->filters()->load('range', ['className' => RangeFilter::class]); $collection = $manager->newCollection(); $collection->add('id', $manager->filters() ->new('range') ->setLabel('Id Range') ->setCriterion($manager->buildCriterion('id', 'integer', $this->Posts)) ); // ... }

Custom Vue.js Widget

Create a custom Vue.js component for the range input. It consists of two parts, widget template and widget component: <!-- /templates/Posts/index.php --> <script type="text/x-template" id="search-input-range-template"> <span class="range-wrapper d-flex"> <input type="number" class="form-control value value-from" :name="'v[' + index + '][from][]'" v-model="fromValue" @input="updateValue" :placeholder="field.fromPlaceholder || 'From'" /> <span class="range-separator d-flex align-items-center">&nbsp;&mdash;&nbsp;</span> <input type="number" class="form-control value value-to" :name="'v[' + index + '][to][]'" v-model="toValue" @input="updateValue" :placeholder="field.toPlaceholder || 'To'" /> </span> </script> <script> const RangeInput = { template: "#search-input-range-template", props: ['index', 'value', 'field'], data() { return { fromValue: '', toValue: '', }; }, methods: { updateValue() { this.$emit('change-value', { index: this.index, value: { from: this.fromValue, to: this.toValue } }); } }, mounted() { if (this.value) { this.fromValue = this.value.from || ''; this.toValue = this.value.to || ''; } }, watch: { value(newValue) { if (newValue) { this.fromValue = newValue.from || ''; this.toValue = newValue.to || ''; } else { this.fromValue = ''; this.toValue = ''; } } } }; <script>

Component Registration

Register the custom widget in the Vue.js app. Implement the register function to register the custom widget, and the setupTable function to setup the table after a htmx request. // /templates/Posts/index.php function register(app, registrator) { app.component('RangeInput', RangeInput); registrator('range', function(cond, type) { return 'RangeInput';}); } function setupTable(reload) { if (reload) { setTimeout(function () { window._search.app.unmount() window._search.createMyApp(window._search.rootElemId, register) }, 20); } } document.addEventListener('DOMContentLoaded', function() { window._search.createMyApp(window._search.rootElemId, register) setupTable(false); htmx.on('htmx:afterRequest', (evt) => { setupTable(true); }) }); </script> This implementation creates a custom range filter that allows users to search for records within a specified numeric range. The filter consists of three main components:
  1. A custom filter class (RangeFilter) that defines the filter type and conditions
  2. A custom criterion class (RangeCriterion) that implements the filtering logic
  3. A Vue.js component (RangeInput) that provides the user interface for entering range values
  4. A registration function to register the custom widget, and the setupTable function to setup the table after a htmx request.

Demo Project for Article

The examples used in this article are located at https://github.com/skie/cakephp-htmx/tree/4.0.0 and available for testing.

Conclusion

The CakeDC Search Filter plugin provides a robust solution for implementing advanced search functionality in CakePHP applications. Its combination of flexible backend filtering and modern frontend components makes it an excellent choice for any CakePHP project. The plugin's extensibility allows for customization to meet specific project needs, while its built-in features cover most common search scenarios out of the box. Whether you need simple text searches or complex multi-criteria filtering, the Search Filter plugin offers the tools to build user-friendly search interfaces. This article is part of the CakeDC Advent Calendar 2024 (December 21th 2024)

5 CakePHP security tips

This article is part of the CakeDC Advent Calendar 2024 (December 20th 2024) We all know the importance of security in our sites, so here we have 5 quick tips that can improve the security of your site quickly:

  • Ensure all cookies are configured for security
// config/app.php 'Session' => [ // .. other configurations 'cookie' => 'CUSTOM_NAME_FOR_YOUR_SESSION_COOKIE', 'ini' => [ 'session.cookie_secure' => true, 'session.cookie_httponly' => true, 'session.cookie_samesite' => 'Strict', ], ],
  • Audit your dependencies
    • Both backend and frontend dependencies could be impacted by security issues. In the case of the backend, you can have a quick look by running composer audit. In case of issues, you'll see an output similar to:
$ composer audit Found 7 security vulnerability advisories affecting 4 packages: +-------------------+----------------------------------------------------------------------------------+ | Package | composer/composer | | CVE | CVE-2024-35241 | | Title | Composer has a command injection via malicious git branch name | | URL | https://github.com/advisories/GHSA-47f6-5gq3-vx9c | | Affected versions | >=2.3,<2.7.7|>=2.0,<2.2.24 | | Reported at | 2024-06-10T21:36:32+00:00 | +-------------------+----------------------------------------------------------------------------------+ // in src/Application::middleware() // Cross Site Request Forgery (CSRF) Protection Middleware // https://book.cakephp.org/4/en/security/csrf.html#cross-site-request-forgery-csrf-middleware ->add(new CsrfProtectionMiddleware([ 'httponly' => true, ]));
  • Enforce HTTPS
    • Ensure your live applications are enforcing HTTPS to prevent downgrading to HTTP. You can handle that in a number of ways, for example using your webserver configuration, or a Proxy. If you want to handle it via CakePHP builtins, add
// in src/Application::middleware() ->add(new HttpsEnforcerMiddleware([ 'hsts' => [ 'maxAge' => 10, 'includeSubDomains' => true, 'preload' => false, // use preload true when you are sure all subdomains are OK with HTTPS ], ])) // in src/Application::middleware() $securityHeaders = (new SecurityHeadersMiddleware()) ->setReferrerPolicy() // limit referrer info leaked ->setXFrameOptions() // mitigates clickjacking attacks ->noOpen() // don't save file in downloads auto ->noSniff(); // mitigates mime type sniffing $middlewareQueue // ... ->add($securityHeaders) // ... This is just a quick example of 5 changes in code you could apply today to improve your CakePHP website security. Keep your projects safe! This article is part of the CakeDC Advent Calendar 2024 (December 20th 2024)

Testing DCI with Behavior-Driven Devel...

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)

Upgrading your old CakePHP application?

This article is part of the CakeDC Advent Calendar 2024 (December 18th 2024) If you have a CakePHP 1 or CakePHP 2 and want to upgrade to the latest version (CakePHP 5) we have some tips that could be useful for you. This is a significant change due to the architectural changes and improvements introduced in the later versions. For these cases, a full rewrite is typically the best path. Here are some tips that have been helpful to us when migrating an application from CakePHP 1 or CakePHP 2 to CakePHP 5: 1. Initial Project Setup in CakePHP 5

2. Rework the MVC Structure
  • Rewrite Models to Use ORM: CakePHP 1.x relied on arrays, while CakePHP 5 uses the ORM, which follows an entity-based approach.
    • Define Entity Classes: Each table should have an entity class (src/Model/Entity/) and a corresponding Table class (src/Model/Table/).
    • Adapt Relationships: Refactor belongsTo, hasMany, hasOne, and hasAndBelongsToMany relationships using the ORM’s syntax in the Table classes.
  • Controllers and Actions: In CakePHP 1.x, controllers and actions were often more procedural. In CakePHP 5, actions are simplified with conventions that follow REST principles.
    • Rewrite Controller Actions: Use new methods in CakePHP 5’s Request and Response objects. Replace any instance of this->data or this->params with $this->getRequest()->getData() and $this->getRequest()->getQuery().
  • Views and Templates: In CakePHP 5, the view layer supports much cleaner, modular, and extensible templating.
    • Refactor Layouts and Templates: Rewrite your .ctp files as .php templates. Consider using reusable templates, layout structures, and components.
    • Use Helper Classes: Rewrite view helpers where needed using CakePHP 5’s built-in helpers. See more details here
3. Refactor Components and Helpers
  • Rewrite Custom Components: Refactor any components or helpers from CakePHP 1 to meet CakePHP 5’s standards. https://book.cakephp.org/5/en/controllers/components.html#components
  • Replace Deprecated Core Components: Components like PaginatorComponent, AuthComponent, SecurityComponent, EmailComponent, ACLComponent  have modern counterparts in CakePHP 5.  For example instead of Security Componete, there are a set of utilities to improve the security in your application, you check them out here here.
4. Leverage the Upgrade Assistance Tool: To help manage your migration process, utilize an upgrade assistance tool that allows you to mark files as migrated. This tool can significantly streamline your workflow by providing a clear overview of your progress and ensuring that no files are overlooked. Make it a habit to mark each file as migrated once you have updated and tested it in the new environment. 5. Test Extensively: Testing is critical in any migration process. As you rewrite and refactor your application, implement new test cases using PHPUnit. Testing each component thoroughly will help you catch and resolve issues early, ensuring a stable and reliable application post-migration.
  Upgrading from CakePHP 1 to CakePHP 5 is a significant task that requires a complete rewrite due to the architectural changes and enhancements in the latest version. By following a structured approach, you can navigate this transition smoothly. Embrace the new features and best practices introduced in CakePHP 5 to build a more robust and maintainable. If you need help with this process you can contact us here we would be happy to help you.         

Streaming database results

This article is part of the CakeDC Advent Calendar 2024 (December 17th 2024) This is a small tip related with an issue we fixed not so long ago. While executing a report, exporting the data as a csv file, we hit a max_memory issue. That's a very common issue when your tables grow bigger and the amount of data you need to export does not fit into the available memory for the PHP process. In that case you have a couple options like:

  • Push the report generation to a background job, then send the result link via email
  • Work with ob_start(), flush() etc
But today we are going to show a way to do it using CakePHP builtin methods. Let's imagine we are exporting a big Users table like: $users = $this->fetchTable('Users')->find(); foreach ($users as $user) { // handle the user row } Causing a 500 error due to a lack of memory to store all the users resultset. And we don't want to process anything in the background, we just want the user to "wait" for the result being generated (note this also could be an issue if it's too long). We could use the following approach in our controller: return $this->response ->withType('text/csv') // or other formats, in case of xml, json, etc ->withDownload('my-big-report.csv') ->withBody(new \Cake\Http\CallbackStream(function () { $users = $this->fetchTable('Users')->find() ->disableBufferedResults() ->disableHydration() ->toArray(); $outstream = fopen("php://output", 'w'); foreach ($users as $user) { fputcsv($outstream, $row); } })); A note about the solution
  • disableBufferedResults does not work for all datasources due to a limitation in PDO. It works for MySQL.
    • In case your are using a not compatible datasource, you'll need to paginate the query manually to get the results in chunks
So, using this approach we are paying time (more queries to the database, longer response time, user waiting) for RAM. Depending on your case, it could be a fair tradeoff. Marc (@ravage84) suggested in slack to also take a look at https://github.com/robotusers/cakephp-chunk too for a way to chunk the results of a query, processing them in a memory efficient way. This article is part of the CakeDC Advent Calendar 2024 (December 17th 2024)

New exciting features in PHP 8.4 Part III

This article is part of the CakeDC Advent Calendar 2024 (December 16th 2024) In this article, we will continue to explore the new features and improvements added in the latest version of PHP 8.4. This is the third article in this series, we encourage you to read the first and second article from the series. In this release, we have received several long-awaited improvements, improved functionality, and a modern approach to common tasks. These few seem to be the most interesting:

  • Lazy Objects
  • Object API for BCMath

Lazy Objects

Lazy objects are objects that are initialized when the state of the object is observed or modified. The use of this type of objects can take place in several scenarios:
  1. Let's assume that you have an object that is expensive to create and may not always be used. For example, when you have an Invoice object that has a LineItems property containing a large amount of data retrieved from the database. If the user asks to display a list of invoices without their content, Lazy Object functionality will prevent unnecessary waste of resources.
  2. Let's assume that you have an object that is expensive to create and you would like to delay its initialization until other expensive operations are performed. For example, you are preparing user data for export, but using Lazy Objects you can delay loading data from the database and perform, for example, authentication to an external API and preparation tasks, and only during export the data from the database will be loaded.
Lazy objects can be created in two strategies:
  1. Ghost Objects - such lazy objects are indistinguishable from normal objects and can be used without knowing that they are lazy.
  2. Virtual Proxies - in this case, the lazy object and the real instance are separate identities, so additional tasks must be performed when accessing the Virtual Proxy.

Creating Ghost Objects

Lazy ghost strategy should be used when we have control over the instatiation and initialization of an object. Lazy ghost is indistinguishable from a real instance of the object. class LazyGhostExample { public function __construct(public string $property) { echo "LazyGhost initialized\n"; } } $reflectionClass = new \ReflectionClass(LazyGhostExample::class); $newLazyGhost = $reflectionClass->newLazyGhost(function (LazyGhostExample $lazyGhostExample) { $lazyGhostExample->__construct('example'); }); // the object is not initialized yet var_dump($newLazyGhost); // the object is of class LazyGhostExample var_dump(get_class($newLazyGhost)); // the object is initialized on the first observation (reading the property) var_dump($newLazyGhost->property); The above example will output: lazy ghost object(LazyGhostExample)#15 (0) { ["property"]=> uninitialized(string) } string(28) "LazyGhostExample" LazyGhost initialized string(7) "example"

Creating Virtual Proxies

Lazy proxies after initialization are intermediaries to the real object instance, each operation on the proxy is redirected to the real object. This approach is good when we do not control the object initialization process. In the example below, we see that we are returning a new instance of the object, thus we do not interfere with what is happening in the constructor. class LazyProxyExample { public function __construct(public string $property) { echo "LazyGhost initialized\n"; } } $reflectionClass = new \ReflectionClass(LazyProxyExample::class); $newLazyProxy = $reflectionClass->newLazyProxy( function (LazyProxyExample $lazyProxyExample): LazyProxyExample { return new LazyProxyExample('example'); } ); // the object is not initialized yet var_dump($newLazyProxy); // the object is of class LazyGhostExample var_dump(get_class($newLazyProxy)); // the object is initialized on the first observation (reading the property) var_dump($newLazyProxy->property); The above example will output: lazy proxy objectLazyProxyExample)#15 (0) { ["property"]=> uninitialized(string) } string(28) "LazyProxyExample" LazyGhost initialized string(7) "example"

What triggers the initialization of Lazy Objects

Lazy Objects are initialized when one of the following operations occurs:
  • reading or writing a property
  • testing whether a property is set or unsetting it
  • reading, changing, or listing a property using the ReflectionProperty and ReflectionObject classes
  • serializing an object or cloning it
  • iterating through an object using foreach if the object does not implement Iterator or IteratorAggregate

Object API for BCMath

Another new feature is an object oriented way of performing mathematical operations on numbers with arbitrary precision numbers. The new class BcMatch\Number is used for this purpose. Below is an example of how to use objects of this class for mathematical operations. $pi = new BcMath\Number('3.14159'); $euler = new BcMath\Number('2.71828'); // we can just sum both instances $sum1 = $pi + $euler; var_dump($sum1); // we can use chaining to do the same $sum2 = $pi->add($euler); var_dump($sum2); // we can compare the objects var_dump($pi > $euler); // we also can compare using method chaining, it this case we will get results // -1 if argument of compare is greater that the number instance // 0 if the argument of compare is equal to number instance // 1 if the argument of compare is greater than number instance var_dump($euler->compare($pi)); This new class is not yet documented in the php documentatjo so is the complete list of methods that can be found in the BcMath\Number class: namespace BcMath { /** * @since 8.4 */ final readonly class Number implements \Stringable { public readonly string $value; public readonly int $scale; public function __construct(string|int $num) {} public function add(Number|string|int $num, ?int $scale = null): Number {} public function sub(Number|string|int $num, ?int $scale = null): Number {} public function mul(Number|string|int $num, ?int $scale = null): Number {} public function div(Number|string|int $num, ?int $scale = null): Number {} public function mod(Number|string|int $num, ?int $scale = null): Number {} /** @return Number[] */ public function divmod(Number|string|int $num, ?int $scale = null): array {} public function powmod(Number|string|int $exponent, Number|string|int $modulus, ?int $scale = null): Number {} public function pow(Number|string|int $exponent, ?int $scale = null): Number {} public function sqrt(?int $scale = null): Number {} public function floor(): Number {} public function ceil(): Number {} public function round(int $precision = 0, \RoundingMode $mode = \RoundingMode::HalfAwayFromZero): Number {} public function compare(Number|string|int $num, ?int $scale = null): int {} public function __toString(): string {} public function __serialize(): array {} public function __unserialize(array $data): void {} } }

Conclusion

The above features greatly extend the capabilities of the PHP language. In addition to these improvements, PHP 8.4 also offers a number of other minor improvements and additions, check the detailed changelog for more information This article is part of the CakeDC Advent Calendar 2024 (December 16th 2024)

Beyond MVC: Data, Context, and Interac...

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

Introduction to the Creator of DCI

The Data-Context-Interaction (DCI) architectural pattern was introduced by Trygve Reenskaug, a Norwegian computer scientist and software engineer. Reenskaug is well known for his contributions to object-oriented programming and design. He is also famous for the development of the Model-View-Controller (MVC) design pattern, which has become a foundational concept in software architecture, especially in web development.

Other artciles of the series

The Emergence of DCI

Reenskaug introduced the DCI pattern as a way to address some of the limitations he observed in traditional object-oriented programming. The DCI pattern aims to separate the concerns of data (the model), the context in which that data is used (the interaction), and the interactions themselves (the roles that objects play in specific scenarios). This separation allows for more maintainable, understandable, and flexible code, making it easier to adapt to changing business requirements.

Classic Implementation

The classic example used to introduce the DCI pattern is the money transfer scenario. This example show how DCI separates the roles of data, context, and interaction, allowing for a clearer understanding of how objects interact in a system. By modeling the transfer of funds between accounts, we can see how the roles of TransferSource and TransferDestination are defined, encapsulating the behaviors associated with withdrawing and depositing money. This separation enhances code maintainability and readability, making it easier to adapt to changing business requirements. classDiagram class TransferSource { +BigDecimal balance +updateBalance(newBalance: BigDecimal): Unit +withdraw(amount: BigDecimal): Unit +canWithdraw(amount: BigDecimal): Boolean } class TransferDestination { +BigDecimal balance +updateBalance(newBalance: BigDecimal): Unit +deposit(amount: BigDecimal): Unit } class Account { +String id +BigDecimal balance } class MoneyTransfer { +Account source +Account destination +BigDecimal amount +execute(): Unit } Account ..|> TransferSource : implements Account ..|> TransferDestination : implements MoneyTransfer --> TransferSource : uses MoneyTransfer --> TransferDestination : uses In the money transfer example, we typically have two accounts: a source account from which funds are withdrawn and a destination account where the funds are deposited. The DCI pattern allows us to define the behaviors associated with these roles separately from the data structure of the accounts themselves. This means that the logic for transferring money can be encapsulated in a context, such as a MoneyTransfer class, which orchestrates the interaction between the source and destination accounts. By doing so, we achieve a more modular and flexible design that can easily accommodate future changes or additional features, such as transaction logging or validation rules. sequenceDiagram participant M as Main participant S as Source Account participant D as Destination Account participant MT as MoneyTransfer M->>S: new Account("1", 1000) with TransferSource M->>D: new Account("2", 500) with TransferDestination M->>MT: new MoneyTransfer(source, destination, 100) M->>MT: execute() MT->>S: canWithdraw(100) alt Source can withdraw S-->>MT: true MT->>S: withdraw(100) S->>S: updateBalance(900) MT->>D: deposit(100) D->>D: updateBalance(600) else Source cannot withdraw S-->>MT: false MT->>M: throw Exception("Source cannot withdraw") end First, I want to show the classic implementation in Scala. By Trygve the language is well suited for this pattern, as traits implementation allow to define the roles and the context in a very clean way and mixins traits into the objects allow explicitely define the roles of the each object. trait TransferSource { def balance: BigDecimal def updateBalance(newBalance: BigDecimal): Unit def withdraw(amount: BigDecimal): Unit = { require(amount > 0, "Amount must be positive") require(balance >= amount, "Insufficient funds") updateBalance(balance - amount) } def canWithdraw(amount: BigDecimal): Boolean = amount > 0 && balance >= amount } trait TransferDestination { def balance: BigDecimal def updateBalance(newBalance: BigDecimal): Unit def deposit(amount: BigDecimal): Unit = { require(amount > 0, "Amount must be positive") updateBalance(balance + amount) } } case class Account(id: String, var balance: BigDecimal) class MoneyTransfer( source: Account with TransferSource, destination: Account with TransferDestination, amount: BigDecimal ) { def execute(): Unit = { require(source.canWithdraw(amount), "Source cannot withdraw") source.withdraw(amount) destination.deposit(amount) } } object Main extends App { val source = new Account("1", 1000) with TransferSource val dest = new Account("2", 500) with TransferDestination val transfer = new MoneyTransfer(source, dest, 100) transfer.execute() }

Basic PHP Implementation

Some languages don't have the same level of flexibility and expressiveness as Scala. Most obvious approach is class wrapper definition for actor roles. I see both pros and cons of this approach. The pros are that it's very easy to understand and implement. The cons are that it's not very flexible and it's not very easy to extend and require additional boilerplate code. Here is the sequence diagram of the implementation: sequenceDiagram participant MT as MoneyTransfer participant S as MoneySource participant D as MoneyDestination participant Source as Source Account participant Destination as Destination Account MT->>S: bind(Source) S->>Source: validatePlayer(Source) alt Player is valid S-->>MT: Player bound successfully else Player is invalid S-->>MT: throw Exception("Player does not meet role requirements") end MT->>D: bind(Destination) D->>Destination: validatePlayer(Destination) alt Player is valid D-->>MT: Player bound successfully else Player is invalid D-->>MT: throw Exception("Player does not meet role requirements") end MT->>S: withdraw(amount) S->>Source: getBalance() Source-->>S: balance alt Insufficient funds S-->>MT: throw Exception("Insufficient funds") else Sufficient funds S->>Source: setBalance(newBalance) S-->>MT: Withdrawal successful end MT->>D: deposit(amount) D->>Destination: getBalance() Destination-->>D: currentBalance D->>Destination: setBalance(newBalance) D-->>MT: Deposit successful MT->>S: unbind() MT->>D: unbind()
  1. First, let's create the Data part (domain objects):
// /src/Model/Entity/Account.php namespace App\Model\Entity; use Cake\ORM\Entity; class Account extends Entity { protected $_accessible = [ 'balance' => true, 'name' => true ]; protected float $balance; public function getBalance(): float { return $this->get('balance'); } public function setBalance(float $amount): void { $this->set('balance', $amount); } }
  1. Create Role management classes:
// /src/Context/Contracts/RoleInterface.php namespace App\Context\Contracts; interface RoleInterface { public function bind($player): void; public function unbind(): void; public function getPlayer(); } // /src/Context/Roles/AbstractRole.php namespace App\Context\Roles; use App\Context\Contracts\RoleInterface; abstract class AbstractRole implements RoleInterface { protected $player; public function bind($player): void { if (!$this->validatePlayer($player)) { throw new \InvalidArgumentException('Player does not meet role requirements'); } $this->player = $player; } public function unbind(): void { $this->player = null; } public function getPlayer() { return $this->player; } abstract protected function validatePlayer($player): bool; }
  1. Create roles that define transfer behaviors:
// /src/Context/Roles/MoneySource.php namespace App\Context\Roles; use App\Model\Entity\Account; class MoneySource extends AbstractRole { protected function validatePlayer($player): bool { return $player instanceof Account && method_exists($player, 'getBalance') && method_exists($player, 'setBalance'); } public function withdraw(float $amount): void { $balance = $this->player->getBalance(); if ($balance < $amount) { throw new \Exception('Insufficient funds'); } $this->player->setBalance($balance - $amount); } } // /src/Context/Roles/MoneyDestination.php namespace App\Context\Roles; use App\Model\Entity\Account; class MoneyDestination extends AbstractRole { protected function validatePlayer($player): bool { return $player instanceof Account && method_exists($player, 'getBalance') && method_exists($player, 'setBalance'); } public function deposit(float $amount): void { $currentBalance = $this->player->getBalance(); $this->player->setBalance($currentBalance + $amount); } }
  1. Create the context that orchestrates the transfer:
// /src/Context/MoneyTransfer.php namespace App\Context; use App\Model\Entity\Account; use App\Context\Roles\MoneySource; use App\Context\Roles\MoneyDestination; class MoneyTransfer { private MoneySource $sourceRole; private MoneyDestination $destinationRole; private float $amount; public function __construct(Account $source, Account $destination, float $amount) { $this->sourceRole = new MoneySource(); $this->sourceRole->bind($source); $this->destinationRole = new MoneyDestination(); $this->destinationRole->bind($destination); $this->amount = $amount; } public function execute(): void { try { $this->sourceRole->withdraw($this->amount); $this->destinationRole->deposit($this->amount); } finally { $this->sourceRole->unbind(); $this->destinationRole->unbind(); } } public function __destruct() { $this->sourceRole->unbind(); $this->destinationRole->unbind(); } }
  1. Implements controller logic
// /src/Controller/AccountsController.php namespace App\Controller; use App\Context\MoneyTransfer; class AccountsController extends AppController { public $Accounts; public function initialize(): void { parent::initialize(); $this->Accounts = $this->fetchTable('Accounts'); } public function transfer() { if ($this->request->is(['post'])) { $sourceAccount = $this->Accounts->get($this->request->getData('source_id')); $destinationAccount = $this->Accounts->get($this->request->getData('destination_id')); $amount = (float)$this->request->getData('amount'); try { $context = new MoneyTransfer($sourceAccount, $destinationAccount, $amount); $context->execute(); $this->Accounts->saveMany([ $sourceAccount, $destinationAccount ]); $this->Flash->success('Transfer completed successfully'); } catch (\Exception $e) { $this->Flash->error($e->getMessage()); } return $this->redirect(['action' => 'transfer']); } $this->set('accounts', $this->Accounts->find('list', valueField: ['name'])->all()); } }

Synthesizing DCI Pattern with CakePHP's Architectural Philosophy

One can look at the roles like a behaviors for table records. We can't use table behaviors directly, because it completely breaks the conception of methods separation based on the roles. In case of table behaviors we can't define methods for different roles for same instance as all class objects will have access to all roles methods. So we're going to implement the behaviors like roles on the entity level.
  1. RoleBehavior layer that mimics CakePHP's behavior system but for entities:
classDiagram class RoleBehavior { #EntityInterface _entity #array _config #array _defaultConfig +__construct(entity: EntityInterface, config: array) +initialize(config: array): void +getConfig(key: string|null, default: mixed): mixed hasProperty(property: string): bool getProperty(property: string): mixed setProperty(property: string, value: mixed): void +implementedMethods(): array +implementedEvents(): array } class ObjectRegistry { #_resolveClassName(class: string): string #_create(class: string, alias: string, config: array): object #_resolveKey(name: string): string +clear(): void } class RoleRegistry { -EntityInterface _entity +__construct(entity: EntityInterface) #_resolveClassName(class: string): string #_create(class: string, alias: string, config: array): RoleBehavior #_resolveKey(name: string): string +clear(): void #_throwMissingClassError(class: string, plugin: string|null): void } class RoleAwareEntity { -RoleRegistry|null _roles -array _roleMethods #_getRoleRegistry(): RoleRegistry +addRole(role: string, config: array): void +removeRole(role: string): void +hasRole(role: string): bool #getRole(role: string): RoleBehavior +__call(method: string, arguments: array) +hasMethod(method: string): bool } ObjectRegistry <|-- RoleRegistry RoleAwareEntity o-- RoleRegistry RoleRegistry o-- RoleBehavior RoleAwareEntity ..> RoleBehavior // /src/Model/Role/RoleBehavior.php namespace App\Model\Role; use Cake\Datasource\EntityInterface; use Cake\Event\EventDispatcherInterface; use Cake\Event\EventDispatcherTrait; abstract class RoleBehavior implements EventDispatcherInterface { use EventDispatcherTrait; protected EntityInterface $_entity; protected array $_config; protected $_defaultConfig = []; public function __construct(EntityInterface $entity, array $config = []) { $this->_entity = $entity; $this->_config = array_merge($this->_defaultConfig, $config); $this->initialize($config); } /** * Initialize hook - like CakePHP behaviors */ public function initialize(array $config): void { } /** * Get behavior config */ public function getConfig(?string $key = null, $default = null): mixed { if ($key === null) { return $this->_config; } return $this->_config[$key] ?? $default; } /** * Check if entity has specific property/method */ protected function hasProperty(string $property): bool { return $this->_entity->has($property); } /** * Get entity property */ protected function getProperty(string $property): mixed { return $this->_entity->get($property); } /** * Set entity property */ protected function setProperty(string $property, mixed $value): void { $this->_entity->set($property, $value); } /** * Get implemented methods - similar to CakePHP behaviors */ public function implementedMethods(): array { return []; } /** * Get implemented events */ public function implementedEvents(): array { return []; } }
  1. Now we can create a RoleRegistry to manage roles for entities:
// /src/Model/Role/RoleRegistry.php namespace App\Model\Role; use Cake\Core\ObjectRegistry; use Cake\Datasource\EntityInterface; use InvalidArgumentException; class RoleRegistry extends ObjectRegistry { private EntityInterface $_entity; public function __construct(EntityInterface $entity) { $this->_entity = $entity; } /** * Should return a string identifier for the object being loaded. * * @param string $class The class name to register. * @return string */ protected function _resolveClassName(string $class): string { if (class_exists($class)) { return $class; } $className = 'App\\Model\\Role\\' . $class . 'Role'; if (!class_exists($className)) { throw new InvalidArgumentException("Role class for '{$class}' not found"); } return $className; } /** * Create an instance of a role. * * @param string $class The class to create. * @param string $alias The alias of the role. * @param array $config The config array for the role. * @return \App\Model\Role\RoleBehavior */ protected function _create($class, string $alias, array $config): RoleBehavior { return new $class($this->_entity, $config); } /** * Get the key used to store roles in the registry. * * @param string $name The role name to get a key for. * @return string */ protected function _resolveKey(string $name): string { return strtolower($name); } /** * Clear all roles from the registry. * * @return void */ public function clear(): void { $this->reset(); } /** * @inheritDoc */ protected function _throwMissingClassError(string $class, ?string $plugin): void { throw new InvalidArgumentException("Role class for '{$class}' not found"); } }
  1. And add role support to Entity:
// /src/Model/Entity/RoleAwareEntity.php namespace App\Model\Entity; use App\Model\Role\RoleBehavior; use App\Model\Role\RoleRegistry; use Cake\ORM\Entity; use BadMethodCallException; class RoleAwareEntity extends Entity { private ?RoleRegistry $_roles = null; private array $_roleMethods = []; protected function _getRoleRegistry(): RoleRegistry { if ($this->_roles === null) { $this->_roles = new RoleRegistry($this); } return $this->_roles; } public function addRole(string $role, array $config = []): void { $roleInstance = $this->_getRoleRegistry()->load($role, $config); foreach ($roleInstance->implementedMethods() as $method => $callable) { $this->_roleMethods[$method] = $role; } } public function removeRole(string $role): void { $this->_roleMethods = array_filter( $this->_roleMethods, fn($roleType) => $roleType !== $role ); $this->_getRoleRegistry()->unload($role); } public function hasRole(string $role): bool { return $this->_getRoleRegistry()->has($role); } protected function getRole(string $role): RoleBehavior { return $this->_getRoleRegistry()->load($role); } public function __call(string $method, array $arguments) { if (isset($this->_roleMethods[$method])) { $role = $this->getRole($this->_roleMethods[$method]); return $role->$method(...$arguments); } throw new BadMethodCallException(sprintf( 'Method %s::%s does not exist', static::class, $method )); } public function hasMethod(string $method): bool { return isset($this->_roleMethods[$method]); } }
  1. Now our Account entity can use roles: // /src/Model/Entity/ComplexAccount.php namespace App\Model\Entity; /** * @method void withdraw(float $amount) * @method bool canWithdraw(float $amount) * @method void deposit(float $amount) * @method bool canDeposit(float $amount) * @method void logOperation(string $operation, array $data) * @method void notify(string $type, array $data) */ class ComplexAccount extends RoleAwareEntity { protected array $_accessible = [ 'balance' => true, 'account_type' => true, 'status' => true, 'is_frozen' => true, 'created' => true, 'modified' => true ]; }
  2. Let's rewrite the money transfer example using our new role layer system:
classDiagram class AuditableBehavior { #Table _auditLogsTable +initialize(config: array): void +logOperation(table: Table, foreignKey: int, operation: string, data: array) } class RoleBehavior { #EntityInterface _entity #array _config #array _defaultConfig +initialize(config: array) +getConfig(key: string|null): mixed #hasProperty(property: string): bool #getProperty(property: string): mixed #setProperty(property: string, value: mixed) } class AuditableRole { +implementedMethods(): array +logOperation(operation: string, data: array): void } class TransferSourceRole { #ComplexAccount _entity #_defaultConfig: array +implementedMethods(): array +withdraw(amount: float): void +canWithdraw(amount: float): bool } class TransferDestinationRole { #ComplexAccount _entity #_defaultConfig: array +implementedMethods(): array +deposit(amount: float): void +canDeposit(amount: float): bool } class MoneyTransferContext { -ComplexAccount source -ComplexAccount destination -float amount -ComplexAccountsTable ComplexAccounts +__construct(ComplexAccountsTable, source, destination, amount, config) -attachRoles(config: array): void +execute(): void -detachRoles(): void } class ComplexAccountsController { +ComplexAccounts +initialize(): void +transfer() } RoleBehavior <|-- AuditableRole RoleBehavior <|-- TransferSourceRole RoleBehavior <|-- TransferDestinationRole MoneyTransferContext --> TransferSourceRole : uses MoneyTransferContext --> TransferDestinationRole : uses MoneyTransferContext --> AuditableRole : uses ComplexAccountsController --> MoneyTransferContext : creates AuditableRole ..> AuditableBehavior : uses note for TransferSourceRole "Handles withdrawal operations\nand balance validation" note for TransferDestinationRole "Handles deposit operations\nand deposit limits" note for AuditableRole "Provides audit logging\ncapabilities" note for MoneyTransferContext "Orchestrates money transfer\nwith role management"

TransferSourceRole

// /src/Model/Role/TransferSourceRole.php namespace App\Model\Role; use App\Model\Entity\ComplexAccount; use Cake\Datasource\EntityInterface; class TransferSourceRole extends RoleBehavior { /** * @var ComplexAccount */ protected EntityInterface $_entity; protected $_defaultConfig = [ 'field' => 'balance', 'minimumBalance' => 0 ]; public function implementedMethods(): array { return [ 'withdraw' => 'withdraw', 'canWithdraw' => 'canWithdraw' ]; } public function withdraw(float $amount): void { if (!$this->canWithdraw($amount)) { throw new \InvalidArgumentException('Cannot withdraw: insufficient funds or invalid amount'); } $balanceField = $this->getConfig('field'); $currentBalance = $this->getProperty($balanceField); $this->_entity->logOperation('pre_withdrawal', [ 'amount' => $amount, 'current_balance' => $currentBalance ]); $this->setProperty($balanceField, $currentBalance - $amount); $this->_entity->logOperation('post_withdrawal', [ 'amount' => $amount, 'new_balance' => $this->getProperty($balanceField) ]); } public function canWithdraw(float $amount): bool { if ($amount <= 0) { return false; } $balanceField = $this->getConfig('field'); $minimumBalance = $this->getConfig('minimumBalance'); return $this->getProperty($balanceField) - $amount >= $minimumBalance && $this->getProperty('status') === 'active' && !$this->getProperty('is_frozen'); } }

TransferDestinationRole

// /src/Model/Role/TransferDestinationRole.php namespace App\Model\Role; use Cake\Datasource\EntityInterface; class TransferDestinationRole extends RoleBehavior { /** * @var ComplexAccount */ protected EntityInterface $_entity; protected $_defaultConfig = [ 'field' => 'balance', 'maxDeposit' => null ]; public function implementedMethods(): array { return [ 'deposit' => 'deposit', 'canDeposit' => 'canDeposit' ]; } public function deposit(float $amount): void { if (!$this->canDeposit($amount)) { throw new \InvalidArgumentException('Cannot deposit: invalid amount or limit exceeded'); } $balanceField = $this->getConfig('field'); $currentBalance = $this->getProperty($balanceField); $this->_entity->logOperation('pre_deposit', [ 'amount' => $amount, 'current_balance' => $currentBalance ]); $this->setProperty($balanceField, $currentBalance + $amount); $this->_entity->logOperation('post_deposit', [ 'amount' => $amount, 'new_balance' => $this->getProperty($balanceField) ]); } public function canDeposit(float $amount): bool { if ($amount <= 0) { return false; } $maxDeposit = $this->getConfig('maxDeposit'); return ($maxDeposit === null || $amount <= $maxDeposit) && $this->getProperty('status') === 'active' && !$this->getProperty('is_frozen'); } }
  1. Lets implement audit functionality to show more complex role usage.

AuditableRole

// /src/Model/Role/AuditableRole.php namespace App\Model\Role; use Cake\ORM\TableRegistry; class AuditableRole extends RoleBehavior { public function implementedMethods(): array { return [ 'logOperation' => 'logOperation' ]; } public function logOperation(string $operation, array $data): void { $table = TableRegistry::getTableLocator()->get($this->_entity->getSource()); $table->logOperation($table, $this->_entity->id, $operation, $data); } }

AuditableBehavior

// /src/Model/Behavior/AuditableBehavior.php namespace App\Model\Behavior; use Cake\ORM\Behavior; use Cake\ORM\Table; use Cake\ORM\TableRegistry; class AuditableBehavior extends Behavior { protected array $_defaultConfig = [ 'implementedMethods' => [ 'logOperation' => 'logOperation', ], ]; protected Table $_auditLogsTable; public function initialize(array $config): void { parent::initialize($config); $this->_auditLogsTable = TableRegistry::getTableLocator()->get('AuditLogs'); } public function logOperation(Table $table, int $foreignKey, string $operation, array $data = []) { $log = $this->_auditLogsTable->newEntity([ 'model' => $table->getAlias(), 'foreign_key' => $foreignKey, 'operation' => $operation, 'data' => json_encode($data), 'created' => new \DateTime() ]); return $this->_auditLogsTable->save($log); } }
  1. Lets take a look on improved context implementation.
// /src/Context/MoneyTransfer/MoneyTransferContext.php namespace App\Context\MoneyTransfer; use App\Model\Entity\ComplexAccount; use App\Model\Table\ComplexAccountsTable; class MoneyTransferContext { private readonly ComplexAccount $source; private readonly ComplexAccount $destination; private readonly float $amount; private readonly ComplexAccountsTable $ComplexAccounts; public function __construct( ComplexAccountsTable $ComplexAccounts, ComplexAccount $source, ComplexAccount $destination, float $amount, array $config = [] ) { $this->source = $source; $this->destination = $destination; $this->amount = $amount; $this->ComplexAccounts = $ComplexAccounts; $this->attachRoles($config); } private function attachRoles(array $config): void { $this->source->addRole('Auditable'); $this->source->addRole('TransferSource', $config['source'] ?? []); $this->destination->addRole('Auditable'); $this->destination->addRole('TransferDestination', $config['destination'] ?? []); } public function execute(): void { try { $this->ComplexAccounts->getConnection()->transactional(function() { if (!$this->source->canWithdraw($this->amount)) { throw new \InvalidArgumentException('Source cannot withdraw this amount'); } if (!$this->destination->canDeposit($this->amount)) { throw new \InvalidArgumentException('Destination cannot accept this deposit'); } $this->source->withdraw($this->amount); $this->destination->deposit($this->amount); // This code will not able to work! Methods not attached not available, and logic errors does not possible to perform in context. // $this->source->deposit($this->amount); // $this->destination->withdraw($this->amount); $this->ComplexAccounts->saveMany([ $this->source, $this->destination ]); }); } finally { $this->detachRoles(); } } private function detachRoles(): void { $this->source->removeRole('TransferSource'); $this->source->removeRole('Auditable'); $this->destination->removeRole('TransferDestination'); $this->destination->removeRole('Auditable'); } }
  1. And finally lets implements controller logic.
// /src/Controller/ComplexAccountsController.php namespace App\Controller; use App\Context\MoneyTransfer\MoneyTransferContext as MoneyTransfer; class ComplexAccountsController extends AppController { public $ComplexAccounts; public function initialize(): void { parent::initialize(); $this->ComplexAccounts = $this->fetchTable('ComplexAccounts'); } public function transfer() { if ($this->request->is(['post'])) { try { $source = $this->ComplexAccounts->get($this->request->getData('source_id')); $destination = $this->ComplexAccounts->get($this->request->getData('destination_id')); $amount = (float)$this->request->getData('amount'); $transfer = new MoneyTransfer($this->ComplexAccounts, $source, $destination, $amount); $transfer->execute(); $this->Flash->success('Transfer completed successfully'); } catch (\InvalidArgumentException $e) { $this->Flash->error($e->getMessage()); } $this->redirect(['action' => 'transfer']); } $this->set('complexAccounts', $this->ComplexAccounts->find('list', valueField: ['account_type', 'id'])->all()); } } The money transfer flow is shown in the following diagram: sequenceDiagram participant CC as ComplexAccountsController participant MT as MoneyTransferContext participant SA as Source Account participant DA as Destination Account participant TSR as TransferSourceRole participant TDR as TransferDestinationRole participant AR as AuditableRole participant AB as AuditableBehavior participant DB as Database CC->>MT: new MoneyTransfer(accounts, source, destination, amount) activate MT MT->>SA: addRole('Auditable') MT->>SA: addRole('TransferSource') MT->>DA: addRole('Auditable') MT->>DA: addRole('TransferDestination') CC->>MT: execute() MT->>SA: canWithdraw(amount) SA->>TSR: canWithdraw(amount) TSR->>SA: getProperty('balance') TSR->>SA: getProperty('status') TSR->>SA: getProperty('is_frozen') TSR-->>MT: true/false alt Can Withdraw MT->>DA: canDeposit(amount) DA->>TDR: canDeposit(amount) TDR->>DA: getProperty('balance') TDR->>DA: getProperty('status') TDR->>DA: getProperty('is_frozen') TDR-->>MT: true/false alt Can Deposit MT->>SA: withdraw(amount) SA->>TSR: withdraw(amount) TSR->>SA: logOperation('pre_withdrawal') SA->>AR: logOperation('pre_withdrawal') AR->>AB: logOperation(table, id, operation, data) AB->>DB: save audit log TSR->>SA: setProperty(balance, newBalance) TSR->>SA: logOperation('post_withdrawal') SA->>AR: logOperation('post_withdrawal') AR->>AB: logOperation(table, id, operation, data) AB->>DB: save audit log MT->>DA: deposit(amount) DA->>TDR: deposit(amount) TDR->>DA: logOperation('pre_deposit') DA->>AR: logOperation('pre_deposit') AR->>AB: logOperation(table, id, operation, data) AB->>DB: save audit log TDR->>DA: setProperty(balance, newBalance) TDR->>DA: logOperation('post_deposit') DA->>AR: logOperation('post_deposit') AR->>AB: logOperation(table, id, operation, data) AB->>DB: save audit log MT->>DB: saveMany([source, destination]) else Cannot Deposit MT-->>CC: throw InvalidArgumentException end else Cannot Withdraw MT-->>CC: throw InvalidArgumentException end MT->>SA: removeRole('TransferSource') MT->>SA: removeRole('Auditable') MT->>DA: removeRole('TransferDestination') MT->>DA: removeRole('Auditable') deactivate MT alt Success CC->>CC: Flash.success('Transfer completed') else Error CC->>CC: Flash.error(error.message) end CC->>CC: redirect(['action' => 'transfer'])

Conclusion

DCI pattern helps us write safer code by controlling what objects can do at any given time. Like in our money transfer example, we make sure the source account can only take money out and the destination account can only receive money. This prevents mistakes and makes the code more secure. Context is a great way to keep code organized and focused. It serves as an excellent implementation of the Single Responsibility Principle. Each context, like our MoneyTransferContext, does just one thing and does it well. This makes the code easier to understand and test because each piece has a clear job to do. Even though PHP isn't as flexible as some other programming languages (for example, we can't change object behavior on the fly), we found good ways to make DCI work. Our RoleBehavior and RoleRegistry classes give us a solid way to manage different roles for our objects. CakePHP turns out to be a great framework for using the DCI pattern. We were able to build on CakePHP's existing features, like its behavior system, to create our role-based approach.

Demo Project for Article

The examples used in this article are located at https://github.com/skie/cakephp-dci and available for testing. This article is part of the CakeDC Advent Calendar 2024 (December 15th 2024)

New exciting features in PHP 8.4 Part II

This article is part of the CakeDC Advent Calendar 2024 (December 14th 2024) In this article, we will continue to explore the new features and improvements added in the latest version of PHP 8.4. In this release, we have received several long-awaited improvements, improved functionality, and a modern approach to common tasks. These few seem to be the most interesting:

  • New ext-dom features and HTML5 support
  • new MyClass()->method() without parentheses
  • #[\Deprecated] Attribute

New ext-dom features and HTML5 support

One of the new feature in the latest PHP release is the new DOM API with support for HTML5, which makes it much easier to work with parsing and extracting data from HTML documents. $html = <<<'HTML' <main> <header> <h1>HTML5 Example Page</h1> </header> <nav class="top-nav"> <ul> <li><a href="">Home</a></li> <li><a href="">About</a></li> <li><a href="">Contact</a></li> </ul> </nav> <article>...</article> <nav class="bottom-nav"> <ul> <li><a href="">Home</a></li> <li><a href="">About</a></li> <li><a href="">Contact</a></li> </ul> </nav> </main> HTML; $htmlDocument = HTMLDocument::createFromString($html); $node = $htmlDocument->querySelector('main > nav:first-of-type'); echo $node->className; // prints top-nav

new MyClass()->method() without parentheses

New expressions can now be used without additional parentheses, making method chaining and property access much easier. // CakePHP collections can be created using new expression without additional parentheses new Collection([1, 2, 3]) ->filter(fn (int $item): bool => $item !== 2) ->map(fn (int $item): string => $item . "000 pcs") ->each(function (string $item): void { echo $item; }) // the same goes for the classes like DateTime or Date echo new DateTime()->addDays(10)->toIso8601String();

New #[\Deprecated] Attribute

The new attribute #[\Deprecated] will allow developers to mark their functions, methods and class constants as deprecated. Using a statement marked with this attribute will result in the error E_USER_DEPRECATED being emitted. This attribute makes it easier to manage deprecated code and uses existing mechanisms in PHP but in user code. class MyClass { #[\Deprecated(message: "use MyClass::MODERN_CLASS_CONST instead", since: "7.1")] public const LEGACY_CLASS_CONST = 'Legacy Class Constant'; public const MODERN_CLASS_CONST = 'Modern Class Constant'; public function doSomething(string $argument) { ... } #[\Deprecated(message: "use MyClass::doSomething() instead", since: "7.1")] public function doSomethingLegacyAndUnsafeWay(string $argument) { ... } } $obj = new MyClass(); $obj->doSomethingLegacyAndUnsafeWay('foo'); echo $obj::LEGACY_CLASS_CONST; The above code will emit warnings: Deprecated: Method MyClass::doSomethingLegacyAndUnsafeWay() is deprecated since 7.1, Use MyClass::doSomething() instead Deprecated: Constant MyClass::LEGACY_CLASS_CONST is deprecated since 7.1, use MyClass::MODERN_CLASS_CONST instead

Conclusion

The above features greatly extend the capabilities of the PHP language. Improved HTML5 support, simplified creation of new objects and deprecation management from PHP will greatly facilitate your tasks in these areas. In addition to these improvements, PHP 8.4 also offers a number of other minor improvements and additions, check the detailed changelog for more information This article is part of the CakeDC Advent Calendar 2024 (December 14th 2024)

New exciting features in PHP 8.4 Part I

This article is part of the CakeDC Advent Calendar 2024 (December 13th 2024) In this article we'll explore some the new features of the recently released PHP 8.4 version. Every year in the second half of autumn a new version of PHP, on which our beloved CakePHP is based, is released. This time it is the major version 8.4, and it adds many exciting features and improvements. Among the many new features and improvements, In this article, we will cover these two interesting added functionalities.:

  • Property hooks
  • Asymmetric Visibility

Property hooks

Property hooks also known as property accessors are a way to intercept and override the way properties are read and written. This new functionality significantly reduces the amount of boilerplate code by allowing you to skip separate getter and setter methods. To use property hooks use get and set hooks class ClassWithPropertyHooks { public string $first_name { get => $this->formatNamePart($this->first_name); set (string $first_name) => trim($first_name); } public string $last_name { get => $this->formatNamePart($this->last_name); set (string $last_name) => trim($last_name); } private function formatNamePart(string $namePart): string { return ucfirst(strtolower($namePart)); } } $obj = new ClassWithPropertyHooks(); $obj->first_name = 'ADAM'; echo $obj->first_name; // prints Adam; $obj->last_name = 'RUSINOWSKI'; echo $obj->last_name; // prints Rusinowski Hooks are placed in {} right after the property declaration, inside you can define both hooks, it is also allowed to define only one, get or set. Each hook can have a body enclosed in {} or if the hook is a single expression, arrow expression can be used. Set Hook can optionally define a name and type of the incoming value, this type must be the same or covariant with the type of the property. All hooks operate in the scope of the object, you can use any public, protected or private method or property inside the body of a hook. Hooks allow you to create virtual properties. Virtual properties are properties that do not have a backed value and no hook directly refers to the value of the property. Instead, the value when read may be the result of some processing or combination of other properties. Virtual properties do not occupy the object's memory and if the set hook is undefined, they cause an error. An example below presents the usage of a virtual property $full_name and the usage of the object method formatNamePart in the body of the hooks: class ClassWithPropertyHooks { public string $first_name { final get => $this->formatNamePart($this->first_name); final set (string $first_name) => trim($first_name); } public string $last_name { get => $this->formatNamePart($this->last_name); set (string $last_name) => trim($last_name); } public ?string $full_name { get { if ($this->first_name || $this->last_name) { return trim("$this->first_name $this->last_name"); } return null; } } private function formatNamePart(string $namePart): string { return ucfirst(strtolower($namePart)); } } $obj = new ClassWithPropertyHooks(); $obj->first_name = 'ADAM'; $obj->last_name = 'rusinowski'; echo $obj->full_name; // prints Adam Rusinowski; $obj->full_name = 'Adam Rusinowski'; // this will cause error since the set hook is not defined Hooks can be made final so they may not be overridden in child class. class ClassWithPropertyHooks { public string $first_name { get => $this->formatNamePart($this->first_name); final set (string $first_name) => trim($first_name); } private function formatNamePart(string $namePart): string { return ucfirst(strtolower($namePart)); } } class ChildClassWithPropertyHooks extends ClassWithPropertyHooks { public string $first_name { get => trim($this->first_name); set (string $first_name) => strtolower($first_name); // this is not allowed } } If you want to have access to the parent hook you can use the syntax parent::$propertyName::get() or parent::$property::set() inside the hook in the child class. class ClassWithPropertyHooks { public string $first_name { get => $this->formatNamePart($this->first_name); set (string $first_name) => trim($first_name); } private function formatNamePart(string $namePart): string { return ucfirst(strtolower($namePart)); } } class ChildClassWithPropertyHooks extends ClassWithPropertyHooks { public string $first_name { get => trim($this->first_name); set (string $first_name) => parent::$first_name::set(strtolower(%$first_name)); } } Properties with property hooks cannot be marked as readonly, so to limit their modification you can use the asymmetric visibility feature.

Asymmetric Visibility

With Asymmetric Visibility you can control the scope of writing and reading properties independently. This reduces the amount of boilerplate code when you want to prohibit modification of property values from outside the class. class ExampleClass { public private(set) int $counter = 0; public function increaseCounter(): void { ++$this->counter; } } $exampleClass = new ExampleClass(); echo $exampleClass->counter; // prints 0; $exampleClass->increaseCounter(); echo $exampleClass->counter; // prints 1; $exampleClass->counter = 5; // this is not allowed Asymmetric Visibility is subject to certain rules:
  • only properties with a defined type can have separate visibility for the set hook
  • the visibility of the set hook must be the same as the visibility of the get hook or more restrictive
  • getting a reference to a property will use the set visibility because the reference can change the value of the property
  • writing to an array property includes the get and set operations internally so the set visibility will be used
When using inheritance, remember that the child class can change the visibility of set and get but if the visibility of set or get is private in the parent class, changing it to something else in the child class will result in a Fatal Error.

Conclusion

The above features greatly extend the capabilities of the PHP language. Property hooks and asymmetric visibility can be useful in value objects, among other things. They can successfully replace getters and setters, thus reducing the amount of boilerplate code in your applications. In the next article, we will cover some more great improvements that the developer community has added to the new version of PHP. This article is part of the CakeDC Advent Calendar 2024 (December 13th 2024)

We Bake with CakePHP