CakeDC Blog

TIPS, INSIGHTS AND THE LATEST FROM THE EXPERTS BEHIND CAKEPHP

CakePHP E2E Testing with Cypress

End-to-End Testing CakePHP Applications with Cypress

End-to-end (E2E) testing has increasingly become a critical part of modern web development workflows. Unit and integration tests are vital, but only End-to-End (E2E) testing accurately verifies the complete user flow, from the browser interface down to the database. For robust applications built with CakePHP 5, E2E tests provide the ultimate safety net.

In this article, we explore how to introduce Cypress, a popular JavaScript-based E2E testing framework, into a CakePHP 5 project. Our goal is to deliver a practical, standards-oriented approach that keeps your application maintainable, predictable, and testable at scale.

1. Why Cypress for CakePHP?

E2E testing has historically been considered slow, brittle, and difficult to maintain. Tools like Selenium or PhantomJS brought automation, but at the cost of complex setup, inconsistent execution, and poor debugging capabilities.

Cypress solves many of these challenges:

  • Runs inside the browser, providing native access to DOM events
  • Offers time-travel debugging for better visibility
  • Ships with a stable execution model — no explicit waits, fewer flaky tests
  • Integrates easily with JavaScript-enabled CakePHP frontends (HTMX, Vue, React, Stimulus, etc.)
  • Provides first-class tools for network mocking, API testing, and fixtures

For CakePHP applications transitioning toward more dynamic, interactive user interfaces, Cypress becomes an essential part of the test strategy.


2. Setting up the Environment: CakePHP 5 & Cypress

Ensure you have a functioning CakePHP 5 application and Cypress installed as a development dependency:

npm init -y
npm install cypress --save-dev

Open Cypress:

npx cypress open

Folder structure:

cypress/
  e2e/
  fixtures/
  support/

2.0. Understanding the Cypress Directory Structure

Running npx cypress open creates the cypress/ folder in the root of your CakePHP project. Understanding its purpose is key to organizing your tests:

Directory Purpose Relevance to CakePHP E2E
cypress/e2e Main tests. Stores all your primary test files (e.g., cart_flow.cy.js). This is where you test your CakePHP routes and UI.
cypress/fixtures Static data such as JSON files for mocking API responses. Useful for mocking external services or complex input data.
cypress/support Reusable code, custom commands, environment config, and global hooks. Crucial for defining the cy.login command using cy.session.
cypress.config.js Main Cypress configuration file. Necessary to integrate Cypress with CakePHP server and DB tasks.

2.1. The Critical E2E Test Selector: data-cy

In E2E testing, relying on standard CSS selectors like id or class is a fragile practice. Designers or frontend developers frequently change these attributes for styling or layout, which immediately breaks your tests.

The best practice is to introduce a dedicated test attribute, such as data-cy. This attribute serves one purpose: E2E testing. It makes your tests resilient to UI changes.

Example in a CakePHP template (.php):

<button type="submit" class="btn btn-primary" data-cy="add-to-cart-button">
    Add to Cart
</button>

Using the selector in Cypress:

cy.get('[data-cy="add-to-cart-button"]').click();

3. E2E Test Case: The Shopping Cart Flow

This section details the construction of our critical E2E test, focusing on the end-user experience: Authentication, Product Addition, and Cart Verification. To ensure test reliability, we prioritize maintaining a clean and known state before execution.


3.1. Resetting the Database (beforeEach)

We must ensure a clean state for every test. Use Cypress tasks to call a CakePHP shell command that drops, migrates, and seeds your dedicated test database.

// In cypress.config.js setupNodeEvents
on('task', {
  resetDb() {
    console.log('Test database reset completed.');
    return null;
  }
});

// In the test file
beforeEach(() => {
  cy.task('resetDb');
});

3.2. Shopping Cart Test (cypress/e2e/cart_flow.cy.js)

This test verifies the successful user journey from browsing to checkout initiation, using the resilient data-cy attributes.

The beforeEach hook ensures that for every test, the database is reset and a user is quickly logged in via session caching.

The it() Block: Core Actions and Assertions

  • Product Selection: Navigate to a specific product page.

    cy.visit('/products/view/1');
  • Add to Cart Action: Locate the "Add to Cart" button using data-cy and click it.

    cy.get('[data-cy="add-to-cart-button"]').click();
  • Confirmation Check: Assert that a visible confirmation message appears.

    cy.get('[data-cy="notification-message"]').should('contain', 'Product added to cart!');
  • Cart Navigation: Navigate to the cart summary page.

    cy.visit('/cart');
  • Content Verification (Assertions): Verify the presence of the product and the correct total price.

    cy.get('[data-cy="cart-item-name-1"]').should('contain', 'Product A');
    cy.get('[data-cy="cart-total-price"]').should('contain', '100.00');
  • Checkout Initiation: Click the link to proceed.

    cy.get('[data-cy="checkout-link"]').should('be.visible').click();
  • Final Navigation Check: Assert that the URL has successfully changed to the checkout route.

    cy.url().should('include', '/checkout');

Test Code (cypress/e2e/cart_flow.cy.js):

/// cypress/e2e/cart_flow.cy.js

describe('E-commerce Shopping Cart Flow', () => {
  beforeEach(() => {
    cy.task('resetDb');
    cy.login('[email protected]', 'secure-password');
  });

  it('Should successfully add an item to the cart and verify the total price', () => {
    cy.visit('/products/view/1');
    cy.get('[data-cy="add-to-cart-button"]').click();
    cy.get('[data-cy="notification-message"]').should('contain', 'Product added to cart!');

    cy.visit('/cart');

    cy.get('[data-cy="cart-item-name-1"]').should('contain', 'Product A');
    cy.get('[data-cy="cart-total-price"]').should('contain', '100.00');

    cy.get('[data-cy="checkout-link"]').should('be.visible').click();
    cy.url().should('include', '/checkout');
  });
});

4. Advanced Good Practices: Optimizing Cypress and E2E Testing

While functional E2E tests are essential, achieving a high-quality, maintainable, and fast test suite requires adopting several advanced practices.


4.1. Fast Authentication with cy.session

A standard E2E test logs in by interacting with the UI (cy.type, cy.click). While accurate, repeating this for every test is slow and inefficient. For subsequent tests in the same flow, we should skip the UI login using Cypress's cy.session.

The cy.session command caches the browser session (cookies, local storage, etc.) after the first successful login. For every test that follows, Cypress restores the session state, avoiding the slow UI login process and drastically reducing execution time.

Implementing the Custom cy.login Command (in cypress/support/commands.js):

Cypress.Commands.add('login', (email, password) => {
    // 1. Define the session identifier (e.g., the user's email)
    const sessionName = email; 

    // 2. Use cy.session to cache the login process
    cy.session(sessionName, () => {
        // This function only runs the first time the sessionName is encountered
        cy.visit('/users/login');
        cy.get('[data-cy="login-email-input"]').type(email);
        cy.get('[data-cy="login-password-input"]').type(password);
        cy.get('[data-cy="login-submit-button"]').click();

        // Assert that the login was successful and the session is ready
        cy.url().should('not.include', '/users/login');
    });

    // After the session is restored/created, navigate to the base URL 
    cy.visit('/'); 
});

4.2. Essential Good Practices for Robust E2E Tests

Here is a list of best practices to ensure your CakePHP 5 E2E tests remain fast, stable, and easy to maintain:

  • Prioritize data-cy Selectors: As discussed, never rely on dynamic attributes like generated IDs, CSS classes (which are prone to styling changes), or nth-child selectors. Use the dedicated data-cy attribute for guaranteed stability.

  • Use Custom Commands for Repetitive Actions: Beyond login, create custom commands (e.g., cy.addItemToCart(itemId)) for any sequence of user actions repeated across multiple tests. This improves readability and reusability.

  • Avoid UI Waiting: Do not use hard-coded waiting times like cy.wait(5000). Cypress is designed to wait automatically for elements to exist and become actionable. If you need to wait for an API call, use cy.intercept() to stub or monitor network requests and then wait specifically for that request to complete (cy.wait('@api-call')).

  • Limit Scope (Test What You Own): E2E tests should focus on your application's logic, not external services (like third-party payment gateways). Use cy.stub() or cy.intercept() to mock these external interactions. If you can test a function at the unit or integration level, avoid duplicating that logic in the slower E2E layer.

  • Test Isolation is Non-Negotiable: Always use the database reset task (cy.task('resetDb')) in a beforeEach hook. Never let one test affect the state of another.

  • Break Down Large Tests: Keep individual it() blocks focused on a single logical assertion or small user journey (e.g., "Add a single item," not "Add item, change quantity, apply coupon, and checkout"). This makes debugging failure points much faster.


5. Conclusion

By combining the architectural strength of CakePHP 5 with the efficiency of Cypress, you build a highly reliable testing pipeline. Utilizing data-cy ensures your tests are stable against UI changes, and leveraging cy.session drastically reduces execution time, making E2E testing a fast and sustainable practice for your development team.

Latest articles

Goodbye to 2025!

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

Pagination of multiple queries in CakePHP

Pagination of multiple queries in CakePHP

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

Users list

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

Pagination of multiple queries

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

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

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

Introduction: The Death of the "Hidden" Dependency

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

Enabling the Delegate

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

How it works under the hood

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

Practical Example: Cleaner Services

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

Why this is a game changer for Testing

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

Conclusion: Small Change, Big Impact

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

We Bake with CakePHP