├── .github └── workflows │ └── ci.yml ├── LICENSE ├── README.md ├── composer.json ├── config └── bootstrap.php ├── docs ├── Ajax.md ├── Component │ └── Ajax.md ├── Contributing.md ├── Install.md ├── README.md └── View │ └── Ajax.md ├── phpcs.xml ├── phpstan.neon ├── src ├── AjaxPlugin.php ├── Controller │ └── Component │ │ └── AjaxComponent.php └── View │ ├── AjaxView.php │ └── JsonEncoder.php └── tests ├── TestCase ├── Controller │ └── Component │ │ └── AjaxComponentTest.php └── View │ └── AjaxViewTest.php └── config └── routes.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | testsuite: 10 | runs-on: ubuntu-22.04 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | php-version: ['8.1', '8.4'] 15 | db-type: [sqlite, mysql, pgsql] 16 | prefer-lowest: [''] 17 | include: 18 | - php-version: '8.1' 19 | db-type: 'sqlite' 20 | prefer-lowest: 'prefer-lowest' 21 | 22 | services: 23 | postgres: 24 | image: postgres 25 | ports: 26 | - 5432:5432 27 | env: 28 | POSTGRES_PASSWORD: postgres 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | - name: Setup Service 34 | if: matrix.db-type == 'mysql' 35 | run: | 36 | sudo service mysql start 37 | mysql -h 127.0.0.1 -u root -proot -e 'CREATE DATABASE cakephp;' 38 | - name: Setup PHP 39 | uses: shivammathur/setup-php@v2 40 | with: 41 | php-version: ${{ matrix.php-version }} 42 | extensions: mbstring, intl, pdo_${{ matrix.db-type }} 43 | coverage: pcov 44 | 45 | - name: Get composer cache directory 46 | id: composercache 47 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 48 | 49 | - name: Cache dependencies 50 | uses: actions/cache@v4 51 | with: 52 | path: ${{ steps.composercache.outputs.dir }} 53 | key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} 54 | 55 | - name: Composer install 56 | run: | 57 | composer --version 58 | if ${{ matrix.prefer-lowest == 'prefer-lowest' }} 59 | then 60 | composer update --prefer-lowest --prefer-stable 61 | composer require --dev dereuromark/composer-prefer-lowest:dev-master 62 | else 63 | composer install --no-progress --prefer-dist --optimize-autoloader 64 | fi 65 | - name: Run PHPUnit 66 | run: | 67 | if [[ ${{ matrix.db-type }} == 'sqlite' ]]; then export DB_URL='sqlite:///:memory:'; fi 68 | if [[ ${{ matrix.db-type }} == 'mysql' ]]; then export DB_URL='mysql://root:root@127.0.0.1/cakephp'; fi 69 | if [[ ${{ matrix.db-type }} == 'pgsql' ]]; then export DB_URL='postgres://postgres:postgres@127.0.0.1/postgres'; fi 70 | if [[ ${{ matrix.php-version }} == '8.1' && ${{ matrix.db-type }} == 'sqlite' ]] 71 | then 72 | vendor/bin/phpunit --coverage-clover=coverage.xml 73 | else 74 | vendor/bin/phpunit 75 | fi 76 | - name: Validate prefer-lowest 77 | if: matrix.prefer-lowest == 'prefer-lowest' 78 | run: vendor/bin/validate-prefer-lowest -m 79 | 80 | - name: Upload coverage reports to Codecov 81 | if: success() && matrix.php-version == '8.1' 82 | uses: codecov/codecov-action@v4 83 | with: 84 | token: ${{ secrets.CODECOV_TOKEN }} 85 | 86 | validation: 87 | name: Coding Standard & Static Analysis 88 | runs-on: ubuntu-22.04 89 | 90 | steps: 91 | - uses: actions/checkout@v4 92 | 93 | - name: Setup PHP 94 | uses: shivammathur/setup-php@v2 95 | with: 96 | php-version: '8.1' 97 | extensions: mbstring, intl 98 | coverage: none 99 | 100 | - name: Get composer cache directory 101 | id: composercache 102 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 103 | 104 | - name: Cache dependencies 105 | uses: actions/cache@v4 106 | with: 107 | path: ${{ steps.composercache.outputs.dir }} 108 | key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} 109 | 110 | - name: Composer Install 111 | run: composer stan-setup 112 | 113 | - name: Run phpstan 114 | run: composer stan 115 | 116 | - name: Run phpcs 117 | run: composer cs-check 118 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Mark Scherer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CakePHP Ajax Plugin 2 | [![CI](https://github.com/dereuromark/cakephp-ajax/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/dereuromark/cakephp-ajax/actions/workflows/ci.yml?query=branch%3Amaster) 3 | [![Coverage Status](https://codecov.io/gh/dereuromark/cakephp-ajax/branch/master/graph/badge.svg)](https://codecov.io/gh/dereuromark/cakephp-ajax) 4 | [![Latest Stable Version](https://poser.pugx.org/dereuromark/cakephp-ajax/v/stable.svg)](https://packagist.org/packages/dereuromark/cakephp-ajax) 5 | [![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.1-8892BF.svg)](https://php.net/) 6 | [![License](https://poser.pugx.org/dereuromark/cakephp-ajax/license.svg)](LICENSE) 7 | [![Total Downloads](https://poser.pugx.org/dereuromark/cakephp-ajax/d/total.svg)](https://packagist.org/packages/dereuromark/cakephp-ajax) 8 | [![Coding Standards](https://img.shields.io/badge/cs-PSR--2--R-yellow.svg)](https://github.com/php-fig-rectified/fig-rectified-standards) 9 | 10 | A CakePHP plugin that makes working with AJAX a "piece of cake". 11 | 12 | This branch is for **CakePHP 5.0+**. For details see [version map](https://github.com/dereuromark/cakephp-ajax/wiki#cakephp-version-map). 13 | 14 | ## What is this plugin for? 15 | Basically DRY (Don't repeat yourself) and easy AJAX handling. 16 | 17 | ### Demo 18 | See the [Sandbox app](https://sandbox.dereuromark.de/sandbox/ajax-examples) for live demos. 19 | 20 | ### Key features 21 | - Auto-handling via View class mapping and making controller actions available both AJAX and non-AJAX by design. 22 | - Flash message and redirect (prevention) support. 23 | 24 | See [my article](https://www.dereuromark.de/2014/01/09/ajax-and-cakephp/) for details on the history of this view class and plugin code. 25 | 26 | ## Installation & Docs 27 | 28 | - [Documentation](docs/README.md) 29 | 30 | ### Possible TODOs 31 | 32 | * Maybe add helpers and additional goodies around auto-complete, edit-in-place, ... 33 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dereuromark/cakephp-ajax", 3 | "description": "A CakePHP plugin that makes working with AJAX a piece of cake.", 4 | "license": "MIT", 5 | "type": "cakephp-plugin", 6 | "keywords": [ 7 | "cakephp", 8 | "plugin", 9 | "AJAX", 10 | "asynchronous", 11 | "view" 12 | ], 13 | "authors": [ 14 | { 15 | "name": "Mark Scherer", 16 | "homepage": "https://www.dereuromark.de", 17 | "role": "Author" 18 | } 19 | ], 20 | "homepage": "https://github.com/dereuromark/cakephp-ajax", 21 | "support": { 22 | "issues": "https://github.com/dereuromark/cakephp-ajax/issues", 23 | "source": "https://github.com/dereuromark/cakephp-ajax" 24 | }, 25 | "require": { 26 | "php": ">=8.1", 27 | "cakephp/cakephp": "^5.0.10" 28 | }, 29 | "require-dev": { 30 | "ext-mbstring": "*", 31 | "dereuromark/cakephp-tools": "^3.0.0", 32 | "fig-r/psr2r-sniffer": "dev-master", 33 | "phpunit/phpunit": "^10.5 || ^11.5 || ^12.1" 34 | }, 35 | "minimum-stability": "stable", 36 | "prefer-stable": true, 37 | "autoload": { 38 | "psr-4": { 39 | "Ajax\\": "src/" 40 | } 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "Ajax\\Test\\": "tests/", 45 | "Cake\\Test\\": "vendor/cakephp/cakephp/tests/", 46 | "TestApp\\": "tests/TestApp/src/" 47 | } 48 | }, 49 | "config": { 50 | "allow-plugins": { 51 | "dealerdirect/phpcodesniffer-composer-installer": true 52 | } 53 | }, 54 | "scripts": { 55 | "cs-check": "phpcs --extensions=php", 56 | "cs-fix": "phpcbf --extensions=php", 57 | "lowest": "validate-prefer-lowest", 58 | "lowest-setup": "composer update --prefer-lowest --prefer-stable --prefer-dist --no-interaction && cp composer.json composer.backup && composer require --dev dereuromark/composer-prefer-lowest && mv composer.backup composer.json", 59 | "stan": "phpstan analyse", 60 | "stan-setup": "cp composer.json composer.backup && composer require --dev phpstan/phpstan:^2.0.0 && mv composer.backup composer.json", 61 | "test": "vendor/bin/phpunit", 62 | "test-coverage": "vendor/bin/phpunit --log-junit tmp/coverage/unitreport.xml --coverage-html tmp/coverage --coverage-clover tmp/coverage/coverage.xml" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /config/bootstrap.php: -------------------------------------------------------------------------------- 1 | Just use JsonView instead. 16 | 17 | Tip: Using RequestHandler component and `.json` extension for your URL will automatically do that for you. 18 | 19 | ### Main use cases 20 | 21 | #### Serving HTML and JSON simultaneously 22 | You have an action that is rendered as HTML usually, but in one instance you need the same data via AJAX. 23 | Instead of duplicating the action, you can leverage the Ajax Component + View class to use the same action for both output types. 24 | Especially the rendered HTML snippet can be the same, for easier use inside the JS frontend then. 25 | It can also on top ship the flash messages that are generated in the process. 26 | 27 | #### Providing a consistent return object for your frontend 28 | 29 | With JsonView your actions might sometimes return a bit inconsistent responses, missing some keys or alike. 30 | The AjaxView is designed to make the response more consistent. 31 | 32 | You either get a 200 status code and your defined response structure: 33 | ```json 34 | { 35 | "content": "[Result of our rendered template.ctp]", 36 | "_redirect": null 37 | } 38 | ``` 39 | 40 | Or you get a non-200 code with the typical error structure: 41 | ```json 42 | { 43 | "code": 404, 44 | "message": "Not Found", 45 | "url": "..." 46 | } 47 | ``` 48 | 49 | A typical JS (e.g. jQuery) code can then easily use that to distinguish those two cases: 50 | ```js 51 | $(function() { 52 | $('#countries').change(function() { 53 | var selectedValue = $(this).val(); 54 | var targetUrl = $(this).attr('rel') + '?id=' + selectedValue; 55 | $.ajax({ 56 | type: 'get', 57 | url: targetUrl, 58 | beforeSend: function(xhr) { 59 | xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); 60 | }, 61 | success: function(response) { 62 | if (response.content) { 63 | $('#provinces').html(response.content); 64 | } 65 | }, 66 | error: function(e) { 67 | alert("An error occurred: " + e.responseText.message); 68 | } 69 | }); 70 | }); 71 | }); 72 | ``` 73 | -------------------------------------------------------------------------------- /docs/Component/Ajax.md: -------------------------------------------------------------------------------- 1 | # Ajax Component 2 | This component works together with the AjaxView to easily switch output type from HTML to JSON 3 | format and adds some additional sugar on top. 4 | Please see the View class docs for the main documentation. 5 | 6 | ## Features 7 | By default the CakePHP RequestHandler, when included, will prevent redirects in AJAX, **but** it will 8 | follow those redirects and return the content via requestAction(). This might not always be desired. 9 | 10 | This plugin prevents this internal request, and instead returns the URL and status code inside the JSON response. 11 | 12 | ## Setup 13 | Load the Ajax component inside `Controller::initialize()`: 14 | ```php 15 | $this->loadComponent('Ajax.Ajax'); 16 | ``` 17 | 18 | You can pass the settings either directly inline here, or use Configure to set them globally. 19 | 20 | If you want to enable it only for certain actions, use the `actions` config key to whitelist certain actions. 21 | You could also do a blacklist programmatically: 22 | ```php 23 | /** 24 | * @return void 25 | */ 26 | public function initialize() { 27 | parent::initialize(); 28 | ... 29 | 30 | // Option A 31 | if (!in_array($this->request->getParam('action'), ['customAction'], true)) { 32 | $this->loadComponent('Ajax.Ajax'); 33 | } 34 | 35 | // Option B (preferred) 36 | $this->loadComponent('Ajax.Ajax', [ 37 | 'actions' => ['customAction'], 38 | ]); 39 | } 40 | ``` 41 | In general, a whitelist setup is usually recommended. 42 | 43 | ## Usage 44 | This component will avoid those redirects completely and pass those down as part of the content of the JSON response object: 45 | 46 | "_redirect":{"url":"http://controller/action","status":200}, ... 47 | 48 | Flash messages are also caught and passed down as part of the response: 49 | 50 | "_message":{"success":["Yeah, that was a normal POST and redirect (PRG)."]}, ... 51 | 52 | Don't forget `Configure::write('Ajax.flashKey', 'FlashMessage');` 53 | if you want to use it with Tools.Flash component (multi/stackable messages). 54 | 55 | You can pass content along with it, as well, those JSON response keys will not be prefixed with a `_` underscore then, as they 56 | are not reserved: 57 | ```php 58 | $content = ['id' => 1, 'title' => 'title']; 59 | $this->set(compact('content')); 60 | $this->set('serialize', ['content']); 61 | ``` 62 | results in 63 | 64 | "content":{...}, ... 65 | 66 | ### AJAX Delete 67 | 68 | For usability reasons you might want to delete a row in a paginated table, without the need to refresh the whole page. 69 | All you need to do here is 70 | - Add a specific class to the "post link" 71 | - Add some custom JS to catch the "post link JS" 72 | - Make sure the AjaxComponent is loaded for this action 73 | 74 | The default bake action usually already works perfectly: 75 | 76 | ```php 77 | public function delete($id = null) { 78 | $this->request->allowMethod(['post', 'delete']); 79 | $group = $this->Groups->get($id); 80 | 81 | $this->Groups->deleteOrFail($group); 82 | $this->Flash->success(__('The group has been deleted.')); 83 | 84 | return $this->redirect(['action' => 'index']); 85 | } 86 | ``` 87 | The JSON response even contains the flash message and redirect URL in case you want to use that in your JS response handling: 88 | ``` 89 | { 90 | "error":null, 91 | "content":null, 92 | "_message":[{"message":"The group has been deleted.","key":"flash","element":"Flash\/success","params":[]}], 93 | "_redirect":{"url":"http:\/\/app.local\/groups","status":302} 94 | } 95 | ``` 96 | 97 | If you have some custom "fail" logic, though, you need to do a small adjustment. 98 | Then just modify your delete action to pass down the error to the view for cases where this is needed: 99 | ```php 100 | public function delete($id = null) { 101 | $this->request->allowMethod(['post', 'delete']); 102 | $group = $this->Groups->get($id); 103 | 104 | if ($group->status === $group::STATUS_PUBLIC) { 105 | $error = 'Already public, deleting not possible in that state.'; 106 | $this->Flash->error($error); 107 | $this->set(compact('error')); 108 | 109 | // Since we are not deleting, referer redirect is safe to use here 110 | return $this->redirect($this->referer(['action' => 'index'], true)); 111 | } 112 | 113 | $this->Groups->deleteOrFail($group); 114 | $this->Flash->success(__('The group has been deleted.')); 115 | 116 | return $this->redirect(['action' => 'index']); 117 | } 118 | ``` 119 | 120 | If you don't pass the error to the view, you would need to read/parse the passed flash messages (key=error), which could be a bit more difficult to do. 121 | But the adjustment above is still minimal (1-2 lines difference from the baked default action for delete case). 122 | 123 | The nice bonus is the auto-fallback: The controller and all deleting works normally for those that have JS disabled. 124 | 125 | A live example can be found in the [Sandbox](https://sandbox.dereuromark.de/sandbox/ajax-examples/table). 126 | 127 | ### Simple boolean response 128 | 129 | In cases like "edit in place" you often just need a basic AJAX response as boolean YES/NO, maybe with an error message on top. 130 | Since we ideally always return a 200 OK response, we need a different way of signaling the frontend if the operation was successful. 131 | 132 | Here you can simplify it using the special "error"/"success" keys that auto-format the reponse as JSON: 133 | ```php 134 | $this->request->allowMethod('post'); 135 | 136 | $value = $this->request->getData('value'); 137 | if (!$this->process($value)) { 138 | $error = 'Didnt work out!'; 139 | $this->set(compact('error')); 140 | } else { 141 | $success = true; // Or a text like 'You did it!' 142 | $this->set(compact('success')); 143 | } 144 | ``` 145 | 146 | In the case of x-editable as "edit in place" JS all you need is to check for the error message: 147 | ```js 148 | success: function(response, newValue) { 149 | if (response.error) { 150 | return response.error; //msg will be shown in editable form 151 | } 152 | } 153 | ``` 154 | 155 | ## Configs 156 | 157 | - 'autoDetect' => true // Detect AJAX automatically, regardless of the extension 158 | - 'resolveRedirect' => true // Send redirects to the view, without actually redirecting 159 | - 'flashKey' => 'Message.flash' // Set to false to disable 160 | - 'actions' => [] // Set to an array of actions if you want to only whitelist these specific actions 161 | -------------------------------------------------------------------------------- /docs/Contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Getting Started 4 | 5 | * Make sure you have a [GitHub account](https://github.com/signup/free) 6 | * Fork the repository on GitHub. 7 | 8 | ## Making Changes 9 | 10 | I am looking forward to your contributions. There are several ways to help out: 11 | * Write missing testcases 12 | * Write patches for bugs/features, preferably with testcases included 13 | 14 | There are a few guidelines that I need contributors to follow: 15 | * Coding standards (`composer cs-check` to check and `composer cs-fix` to fix) 16 | * Passing tests (you can enable travis to assert your changes pass) for Windows and Unix (`php phpunit.phar`) 17 | 18 | Tip: You can use the composer commands to set up everything: 19 | * `composer install` 20 | 21 | Now you can run the tests via `composer test` and get coverage via `composer test-coverage` commands. 22 | 23 | # Additional Resources 24 | 25 | * [Coding standards guide (extending/overwriting the CakePHP ones)](https://github.com/php-fig-rectified/fig-rectified-standards/) 26 | * [CakePHP coding standards](https://book.cakephp.org/3.0/en/contributing/cakephp-coding-conventions.html) 27 | * [General GitHub documentation](https://help.github.com/) 28 | * [GitHub pull request documentation](https://help.github.com/send-pull-requests/) 29 | -------------------------------------------------------------------------------- /docs/Install.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## How to include 4 | Installing the Plugin is pretty much as with every other CakePHP Plugin. 5 | 6 | ``` 7 | composer require dereuromark/cakephp-ajax 8 | ``` 9 | 10 | Details @ https://packagist.org/packages/dereuromark/cakephp-ajax 11 | 12 | Load the plugin: 13 | ``` 14 | bin/cake plugin load Ajax 15 | ``` 16 | 17 | Note that you do not have to load the plugin if you do not use the plugin's bootstrap or require other plugins (like IdeHelper) to know about it. It also doesn't hurt to load it, though. 18 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # CakePHP Ajax Plugin Documentation 2 | 3 | ## Installation 4 | * [Installation](Install.md) 5 | 6 | ## Documentation 7 | * [Ajax plugin](Ajax.md) 8 | * [View/Ajax](View/Ajax.md) 9 | * [Component/Ajax](Component/Ajax.md) 10 | 11 | ## Contributing 12 | Your help is greatly appreciated. 13 | 14 | * See [Contributing](Contributing.md) for details. 15 | -------------------------------------------------------------------------------- /docs/View/Ajax.md: -------------------------------------------------------------------------------- 1 | # Ajax View 2 | 3 | A CakePHP view class to make working with AJAX a bit easier. 4 | 5 | ## Configs 6 | First enable JSON extensions if you want to use `json` extension. 7 | You can either use the included bootstrap file, or add the snippet manually to your own one: 8 | ``` 9 | Router::extensions(['json']); 10 | ``` 11 | 12 | ## Usage 13 | Using the `json` extension you can then access your action through the following URL: 14 | ``` 15 | /controller/action.json 16 | ``` 17 | 18 | You can enable the AjaxView class it in your actions like so: 19 | ```php 20 | // new 21 | $this->viewBuilder()->setClassName('Ajax.Ajax'); 22 | 23 | // old 24 | $this->viewClass = 'Ajax.Ajax'; 25 | ``` 26 | Using the AjaxComponent you can save yourself that call, as it can auto-detect AJAX request. 27 | 28 | 29 | ### Basic view rendering 30 | Instead of GET we request it via AJAX: 31 | ```php 32 | public function favorites() { 33 | $this->request->allowMethod('ajax'); 34 | $this->viewClass = 'Ajax.Ajax'; // Only necessary without the Ajax component 35 | } 36 | ``` 37 | 38 | The result can be this, for example: 39 | ``` 40 | { 41 | "content": [Result of our rendered favorites.ctp as HTML string], 42 | "error": '' 43 | } 44 | ``` 45 | You can add more data to the response object via `serialize`. 46 | 47 | 48 | ### Drop down selections 49 | ```php 50 | public function statesAjax() { 51 | $this->request->allowMethod('ajax'); 52 | $id = $this->request->getQuery('id'); 53 | if (!$id) { 54 | throw new NotFoundException(); 55 | } 56 | 57 | $this->viewClass = 'Ajax.Ajax'; // Only necessary without the Ajax component 58 | 59 | $states = $this->States->getListByCountry($id); 60 | $this->set(compact('states')); 61 | } 62 | ``` 63 | 64 | ## Custom Plugin helpers 65 | If your view classes needs additional plugin helpers, and you are not using the controller way anymore to load/define helpers, then you might need to extend the view class to project level and add them there: 66 | ```php 67 | namespace App\View; 68 | 69 | use Ajax\View\AjaxView as PluginAjaxView; 70 | 71 | class AjaxView extends PluginAjaxView { 72 | 73 | /** 74 | * @return void 75 | */ 76 | public function initialize() { 77 | parent::initialize(); 78 | $this->loadHelper('...); 79 | ... 80 | } 81 | 82 | } 83 | ``` 84 | Then make sure you load the app `Ajax` view class instead of the `Ajax.Ajax` one. 85 | If you are using the component, you can set Configure key `'Ajax.viewClass'` to your `'Ajax'` here. 86 | 87 | ## Tips 88 | I found the following quite useful for your jQuery AJAX code as some browsers might not properly work without it (at least for me it used to). 89 | ``` 90 | beforeSend: function(xhr) { 91 | xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); 92 | }, 93 | ``` 94 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | config 7 | src 8 | tests 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | paths: 4 | - src/ 5 | bootstrapFiles: 6 | - %rootDir%/../../../tests/bootstrap.php 7 | ignoreErrors: 8 | - identifier: missingType.generics 9 | -------------------------------------------------------------------------------- /src/AjaxPlugin.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | protected array $_defaultConfig = [ 35 | 'viewClass' => 'Ajax.Ajax', 36 | 'autoDetect' => true, 37 | 'resolveRedirect' => true, 38 | 'flashKey' => 'Flash.flash', 39 | 'actions' => [], 40 | ]; 41 | 42 | /** 43 | * @param \Cake\Controller\ComponentRegistry $collection 44 | * @param array $config 45 | */ 46 | public function __construct(ComponentRegistry $collection, $config = []) { 47 | $defaults = (array)Configure::read('Ajax') + $this->_defaultConfig; 48 | $config += $defaults; 49 | parent::__construct($collection, $config); 50 | } 51 | 52 | /** 53 | * @param array $config 54 | * @return void 55 | */ 56 | public function initialize(array $config): void { 57 | if (!$this->_config['autoDetect'] || !$this->_isActionEnabled()) { 58 | return; 59 | } 60 | $this->respondAsAjax = $this->getController()->getRequest()->is('ajax'); 61 | } 62 | 63 | /** 64 | * Called before the Controller::beforeRender(), and before 65 | * the view class is loaded, and before Controller::render() 66 | * 67 | * @param \Cake\Event\EventInterface $event 68 | * @return void 69 | */ 70 | public function beforeRender(EventInterface $event): void { 71 | if (!$this->respondAsAjax) { 72 | return; 73 | } 74 | 75 | $this->_respondAsAjax(); 76 | } 77 | 78 | /** 79 | * @return void 80 | */ 81 | protected function _respondAsAjax(): void { 82 | $this->getController()->viewBuilder()->setClassName($this->_config['viewClass']); 83 | 84 | // Set flash messages to the view 85 | if ($this->_config['flashKey']) { 86 | $message = $this->getController()->getRequest()->getSession()->consume($this->_config['flashKey']); 87 | $this->getController()->set('_message', $message); 88 | } 89 | 90 | // If `serialize` is true, *all* viewVars will be serialized; no need to add _message. 91 | if ($this->_isControllerSerializeTrue()) { 92 | return; 93 | } 94 | 95 | $serializeKeys = ['_message']; 96 | if (!empty($this->getController()->viewBuilder()->getVar('serialize'))) { 97 | $serializeKeys = array_merge($serializeKeys, (array)$this->getController()->viewBuilder()->getVar('serialize')); 98 | } 99 | $this->getController()->set('serialize', $serializeKeys); 100 | } 101 | 102 | /** 103 | * Called before Controller::redirect(). Allows you to replace the URL that will 104 | * be redirected to with a new URL. 105 | * 106 | * @param \Cake\Event\EventInterface $event Event 107 | * @param array|string $url Either the string or URL array that is being redirected to. 108 | * @param \Cake\Http\Response $response 109 | * @return void 110 | */ 111 | public function beforeRedirect(EventInterface $event, $url, Response $response): void { 112 | if (!$this->respondAsAjax || !$this->_config['resolveRedirect']) { 113 | return; 114 | } 115 | 116 | $url = Router::url($url, true); 117 | 118 | $status = $response->getStatusCode(); 119 | $response = $response->withStatus(200)->withoutHeader('Location'); 120 | $this->getController()->setResponse($response); 121 | 122 | $this->getController()->enableAutoRender(); 123 | $this->getController()->set('_redirect', compact('url', 'status')); 124 | 125 | $event->stopPropagation(); 126 | 127 | if ($this->_isControllerSerializeTrue()) { 128 | return; 129 | } 130 | 131 | $serializeKeys = ['_redirect']; 132 | if ($this->getController()->viewBuilder()->getVar('serialize')) { 133 | $serializeKeys = array_merge($serializeKeys, (array)$this->getController()->viewBuilder()->getVar('serialize')); 134 | } 135 | $this->getController()->set('serialize', $serializeKeys); 136 | // Further changes will be required here when the change to immutable response objects is completed 137 | $response = $this->getController()->render(); 138 | $event->setResult($response); 139 | } 140 | 141 | /** 142 | * Checks to see if the Controller->viewVar labeled `serialize` is set to boolean true. 143 | * 144 | * @return bool 145 | */ 146 | protected function _isControllerSerializeTrue(): bool { 147 | if ($this->getController()->viewBuilder()->getVar('serialize') === true) { 148 | return true; 149 | } 150 | 151 | return false; 152 | } 153 | 154 | /** 155 | * Checks if we are using action whitelisting and if so checks if this action is whitelisted. 156 | * 157 | * @return bool 158 | */ 159 | protected function _isActionEnabled(): bool { 160 | $actions = $this->getConfig('actions'); 161 | if (!$actions) { 162 | return true; 163 | } 164 | 165 | return in_array($this->getController()->getRequest()->getParam('action'), $actions, true); 166 | } 167 | 168 | } 169 | -------------------------------------------------------------------------------- /src/View/AjaxView.php: -------------------------------------------------------------------------------- 1 | 32 | */ 33 | protected array $_passedVars = [ 34 | 'viewVars', 'autoLayout', 'ext', 'helpers', 'view', 'layout', 'name', 'theme', 35 | 'layoutPath', 'plugin', 'passedArgs', 'subDir', 'template', 'templatePath', 36 | ]; 37 | 38 | /** 39 | * The subdirectory. AJAX views are always in ajax. 40 | * 41 | * @var string 42 | */ 43 | protected string $subDir = ''; 44 | 45 | /** 46 | * List of special view vars. 47 | * 48 | * @var array 49 | */ 50 | protected array $_specialVars = ['serialize', '_jsonOptions', '_jsonp']; 51 | 52 | /** 53 | * Constructor 54 | * 55 | * @param \Cake\Http\ServerRequest|null $request Request instance. 56 | * @param \Cake\Http\Response|null $response Response instance. 57 | * @param \Cake\Event\EventManager|null $eventManager Event manager instance. 58 | * @param array $viewOptions View options. See View::$_passedVars for list of 59 | * options which get set as class properties. 60 | */ 61 | public function __construct( 62 | ?ServerRequest $request = null, 63 | ?Response $response = null, 64 | ?EventManager $eventManager = null, 65 | array $viewOptions = [], 66 | ) { 67 | parent::__construct($request, $response, $eventManager, $viewOptions); 68 | 69 | $this->disableAutoLayout(); 70 | 71 | if ($this->subDir === '') { 72 | $this->subDir = 'ajax'; 73 | $this->templatePath = str_replace(DS . 'json', '', $this->templatePath); 74 | $this->templatePath = str_replace(DS . 'ajax', '', $this->templatePath); 75 | } 76 | 77 | if (isset($response)) { 78 | $response = $response->withType('json'); 79 | $this->response = $response; 80 | } 81 | } 82 | 83 | /** 84 | * Renders an AJAX view. 85 | * The rendered content will be part of the JSON response object and 86 | * can be accessed via `response.content` in JavaScript. 87 | * 88 | * If an error or success has been set, the rendering will be skipped. 89 | * 90 | * @param string|null $view The view being rendered. 91 | * @param string|null $layout The layout being rendered. 92 | * @return string The rendered view. 93 | */ 94 | public function render(?string $view = null, $layout = null): string { 95 | $dataToSerialize = [ 96 | 'error' => null, 97 | 'success' => null, 98 | 'content' => null, 99 | ]; 100 | 101 | if ($view === null) { 102 | $view = $this->template; 103 | } 104 | if ($view === '') { 105 | $view = null; 106 | } 107 | 108 | if (isset($this->viewVars['error'])) { 109 | $dataToSerialize['error'] = $this->viewVars['error']; 110 | $view = null; 111 | } 112 | if (isset($this->viewVars['success'])) { 113 | $dataToSerialize['success'] = $this->viewVars['success']; 114 | $view = null; 115 | } 116 | 117 | if ($view !== null && !isset($this->viewVars['_redirect']) && $this->_getTemplateFileName($view)) { 118 | $dataToSerialize['content'] = parent::render($view, $layout); 119 | } 120 | 121 | $this->viewVars = Hash::merge($dataToSerialize, $this->viewVars); 122 | if (isset($this->viewVars['serialize'])) { 123 | $dataToSerialize = $this->_dataToSerialize($this->viewVars['serialize'], $dataToSerialize); 124 | } 125 | 126 | return $this->_serialize($dataToSerialize); 127 | } 128 | 129 | /** 130 | * Serialize(json_encode) accumulated data from both our custom render method 131 | * and viewVars set by the user. 132 | * 133 | * @param array $dataToSerialize Array of data that is to be serialzed. 134 | * @return string The serialized data. 135 | */ 136 | protected function _serialize(array $dataToSerialize = []): string { 137 | return JsonEncoder::encode($dataToSerialize); 138 | } 139 | 140 | /** 141 | * Returns data to be serialized based on the value of viewVars. 142 | * 143 | * @param array|string|bool $serialize The name(s) of the view variable(s) that 144 | * need(s) to be serialized. If true all available view variables will be used. 145 | * @param array $additionalData Data items that were defined internally in our own 146 | * render method. 147 | * @return array The data to serialize. 148 | */ 149 | protected function _dataToSerialize(array|bool|string $serialize, array $additionalData = []): array { 150 | if ($serialize === true) { 151 | $data = array_diff_key( 152 | $this->viewVars, 153 | array_flip($this->_specialVars), 154 | ); 155 | 156 | return $data; 157 | } 158 | 159 | foreach ((array)$serialize as $alias => $key) { 160 | if (is_numeric($alias)) { 161 | $alias = $key; 162 | } 163 | if (array_key_exists($key, $this->viewVars)) { 164 | $additionalData[$alias] = $this->viewVars[$key]; 165 | } 166 | } 167 | 168 | return $additionalData; 169 | } 170 | 171 | } 172 | -------------------------------------------------------------------------------- /src/View/JsonEncoder.php: -------------------------------------------------------------------------------- 1 | $dataToSerialize 11 | * @param int $options 12 | * 13 | * @throws \RuntimeException 14 | * 15 | * @return string 16 | */ 17 | public static function encode(array $dataToSerialize, int $options = 0): string { 18 | $result = json_encode($dataToSerialize, $options); 19 | 20 | $error = null; 21 | if (json_last_error() !== JSON_ERROR_NONE) { 22 | $error = 'JSON encoding failed: ' . json_last_error_msg(); 23 | } 24 | 25 | if ($result === false || $error) { 26 | throw new RuntimeException($error ?: 'JSON encoding failed'); 27 | } 28 | 29 | return $result; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /tests/TestCase/Controller/Component/AjaxComponentTest.php: -------------------------------------------------------------------------------- 1 | Controller = new AjaxTestController(new ServerRequest(), new Response()); 41 | } 42 | 43 | /** 44 | * @return void 45 | */ 46 | public function testNonAjax() { 47 | $this->Controller->startupProcess(); 48 | $this->assertFalse($this->Controller->components()->Ajax->respondAsAjax); 49 | } 50 | 51 | /** 52 | * @throws \Exception 53 | * @return void 54 | */ 55 | public function testDefaults() { 56 | $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; 57 | 58 | $this->Controller = new AjaxTestController(new ServerRequest(), new Response()); 59 | $this->Controller->components()->load('Flash'); 60 | 61 | $this->assertTrue($this->Controller->components()->Ajax->respondAsAjax); 62 | 63 | $this->Controller->components()->Flash->custom('A message'); 64 | $session = $this->Controller->getRequest()->getSession()->read('Flash.flash'); 65 | $expected = [ 66 | [ 67 | 'message' => 'A message', 68 | 'key' => 'flash', 69 | 'element' => 'flash/custom', 70 | 'params' => [], 71 | ], 72 | ]; 73 | $this->assertEquals($expected, $session); 74 | 75 | $event = new Event('Controller.beforeRender'); 76 | $this->Controller->components()->Ajax->beforeRender($event); 77 | 78 | $this->assertEquals('Ajax.Ajax', $this->Controller->viewBuilder()->getClassName()); 79 | $this->assertEquals($expected, $this->Controller->viewBuilder()->getVar('_message')); 80 | 81 | $session = $this->Controller->getRequest()->getSession()->read('Flash.flash'); 82 | $this->assertNull($session); 83 | 84 | $this->Controller->redirect('/'); 85 | $expected = [ 86 | 'Content-Type' => [ 87 | 'application/json', 88 | ], 89 | ]; 90 | $this->assertSame($expected, $this->Controller->getResponse()->getHeaders()); 91 | 92 | $expected = [ 93 | 'url' => Router::url('/', true), 94 | 'status' => 302, 95 | ]; 96 | $this->assertEquals($expected, $this->Controller->viewBuilder()->getVar('_redirect')); 97 | } 98 | 99 | /** 100 | * @throws \Exception 101 | * @return void 102 | */ 103 | public function testAutoDetectOnFalse() { 104 | $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; 105 | 106 | $this->Controller = new AjaxTestController(new ServerRequest(), new Response()); 107 | 108 | $this->Controller->components()->unload('Ajax'); 109 | $this->Controller->components()->load('Ajax.Ajax', ['autoDetect' => false]); 110 | 111 | $this->Controller->startupProcess(); 112 | $this->assertFalse($this->Controller->components()->Ajax->respondAsAjax); 113 | } 114 | 115 | /** 116 | * @throws \Exception 117 | * @return void 118 | */ 119 | public function testActionsInvalid() { 120 | $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; 121 | Configure::write('Ajax.actions', ['foo']); 122 | 123 | $this->Controller = new AjaxTestController(new ServerRequest(), new Response()); 124 | 125 | $this->Controller->components()->unload('Ajax'); 126 | $this->Controller->components()->load('Ajax.Ajax'); 127 | 128 | $this->Controller->startupProcess(); 129 | $this->assertFalse($this->Controller->components()->Ajax->respondAsAjax); 130 | } 131 | 132 | /** 133 | * @throws \Exception 134 | * @return void 135 | */ 136 | public function testActions() { 137 | $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; 138 | Configure::write('Ajax.actions', ['foo']); 139 | 140 | $this->Controller = new AjaxTestController(new ServerRequest(['params' => ['action' => 'foo']]), new Response()); 141 | 142 | $this->Controller->components()->unload('Ajax'); 143 | $this->Controller->components()->load('Ajax.Ajax'); 144 | 145 | $this->Controller->startupProcess(); 146 | $this->assertTrue($this->Controller->components()->Ajax->respondAsAjax); 147 | } 148 | 149 | /** 150 | * @throws \Exception 151 | * @return void 152 | */ 153 | public function testAutoDetectOnFalseViaConfig() { 154 | $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; 155 | Configure::write('Ajax.autoDetect', false); 156 | 157 | $this->Controller = new AjaxTestController(new ServerRequest(), new Response()); 158 | 159 | $this->Controller->components()->unload('Ajax'); 160 | $this->Controller->components()->load('Ajax.Ajax'); 161 | 162 | $this->Controller->startupProcess(); 163 | $this->assertFalse($this->Controller->components()->Ajax->respondAsAjax); 164 | } 165 | 166 | /** 167 | * @throws \Exception 168 | * @return void 169 | */ 170 | public function testSetVars() { 171 | $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; 172 | 173 | $this->Controller = new AjaxTestController(new ServerRequest(), new Response()); 174 | 175 | $this->Controller->components()->unload('Ajax'); 176 | 177 | $content = ['id' => 1, 'title' => 'title']; 178 | $this->Controller->set(compact('content')); 179 | $this->Controller->set('serialize', ['content']); 180 | 181 | $this->Controller->components()->load('Ajax.Ajax'); 182 | $this->assertNotEmpty($this->Controller->viewBuilder()->getVars()); 183 | $this->assertNotEmpty($this->Controller->viewBuilder()->getVar('serialize')); 184 | $this->assertEquals('content', $this->Controller->viewBuilder()->getVar('serialize')[0]); 185 | } 186 | 187 | /** 188 | * @return void 189 | */ 190 | public function testSetVarsWithRedirect() { 191 | $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; 192 | 193 | $this->Controller = new AjaxTestController(new ServerRequest(), new Response()); 194 | $this->Controller->startupProcess(); 195 | 196 | $content = ['id' => 1, 'title' => 'title']; 197 | $this->Controller->set(compact('content')); 198 | $this->Controller->set('serialize', ['content']); 199 | 200 | // Let's try a permanent redirect 201 | $this->Controller->redirect('/', 301); 202 | $expected = [ 203 | 'Content-Type' => [ 204 | 'application/json', 205 | ], 206 | ]; 207 | $this->assertSame($expected, $this->Controller->getResponse()->getHeaders()); 208 | 209 | $expected = [ 210 | 'url' => Router::url('/', true), 211 | 'status' => 301, 212 | ]; 213 | $this->assertEquals($expected, $this->Controller->viewBuilder()->getVar('_redirect')); 214 | 215 | $this->Controller->set(['_message' => 'test']); 216 | $this->Controller->redirect('/'); 217 | $this->assertArrayHasKey('_message', $this->Controller->viewBuilder()->getVars()); 218 | 219 | $this->assertNotEmpty($this->Controller->viewBuilder()->getVars()); 220 | $this->assertNotEmpty($this->Controller->viewBuilder()->getVar('serialize')); 221 | $this->assertTrue(in_array('content', $this->Controller->viewBuilder()->getVar('serialize'))); 222 | } 223 | 224 | } 225 | -------------------------------------------------------------------------------- /tests/TestCase/View/AjaxViewTest.php: -------------------------------------------------------------------------------- 1 | Ajax = new AjaxView(); 38 | } 39 | 40 | /** 41 | * @return void 42 | */ 43 | public function testSerialize() { 44 | $Request = new ServerRequest(); 45 | $Response = new Response(); 46 | $items = [ 47 | ['title' => 'Title One', 'link' => 'http://example.org/one', 'author' => 'one@example.org', 'description' => 'Content one'], 48 | ['title' => 'Title Two', 'link' => 'http://example.org/two', 'author' => 'two@example.org', 'description' => 'Content two'], 49 | ]; 50 | $View = new AjaxView($Request, $Response); 51 | $View->set(['items' => $items, 'serialize' => ['items']]); 52 | $result = $View->render(''); 53 | 54 | $response = $View->getResponse(); 55 | $this->assertSame('application/json', $response->getType()); 56 | $expected = ['error' => null, 'success' => null, 'content' => null, 'items' => $items]; 57 | $expected = json_encode($expected); 58 | $this->assertTextEquals($expected, $result); 59 | } 60 | 61 | /** 62 | * @return void 63 | */ 64 | public function testRenderWithSerialize() { 65 | $Request = new ServerRequest(); 66 | $Response = new Response(); 67 | $items = [ 68 | ['title' => 'Title One', 'link' => 'http://example.org/one', 'author' => 'one@example.org', 'description' => 'Content one'], 69 | ['title' => 'Title Two', 'link' => 'http://example.org/two', 'author' => 'two@example.org', 'description' => 'Content two'], 70 | ]; 71 | $View = new AjaxView($Request, $Response); 72 | $View->set(['items' => $items, 'serialize' => 'items']); 73 | $View->setTemplatePath('Items'); 74 | $result = $View->render('index'); 75 | 76 | $response = $View->getResponse(); 77 | $this->assertSame('application/json', $response->getType()); 78 | $expected = ['error' => null, 'success' => null, 'content' => 'My Ajax Index Test ctp' . PHP_EOL, 'items' => $items]; 79 | $expected = json_encode($expected); 80 | $this->assertTextEquals($expected, $result); 81 | } 82 | 83 | /** 84 | * Test the case where the `serialize` viewVar is set to true signaling that all viewVars 85 | * should be serialized. 86 | * 87 | * @return void 88 | */ 89 | public function testSerializeSetTrue() { 90 | $Request = new ServerRequest(); 91 | $Response = new Response(); 92 | $items = [ 93 | ['title' => 'Title One', 'link' => 'http://example.org/one', 'author' => 'one@example.org', 'description' => 'Content one'], 94 | ['title' => 'Title Two', 'link' => 'http://example.org/two', 'author' => 'two@example.org', 'description' => 'Content two'], 95 | ]; 96 | $multiple = 'items'; 97 | $View = new AjaxView($Request, $Response); 98 | $View->set(['items' => $items, 'multiple' => $multiple, 'serialize' => true]); 99 | $result = $View->render(''); 100 | 101 | $response = $View->getResponse(); 102 | $this->assertSame('application/json', $response->getType()); 103 | $expected = ['error' => null, 'success' => null, 'content' => null, 'items' => $items, 'multiple' => $multiple]; 104 | $expected = json_encode($expected); 105 | $this->assertTextEquals($expected, $result); 106 | } 107 | 108 | /** 109 | * @return void 110 | */ 111 | public function testError() { 112 | $Request = new ServerRequest(); 113 | $Response = new Response(); 114 | $items = [ 115 | ['title' => 'Title One', 'link' => 'http://example.org/one', 'author' => 'one@example.org', 'description' => 'Content one'], 116 | ['title' => 'Title Two', 'link' => 'http://example.org/two', 'author' => 'two@example.org', 'description' => 'Content two'], 117 | ]; 118 | $View = new AjaxView($Request, $Response); 119 | $View->set(['error' => 'Some message', 'items' => $items, 'serialize' => ['error', 'items']]); 120 | $View->setTemplatePath('Items'); 121 | $result = $View->render('index'); 122 | 123 | $response = $View->getResponse(); 124 | $this->assertSame('application/json', $response->getType()); 125 | $expected = ['error' => 'Some message', 'success' => null, 'content' => null, 'items' => $items]; 126 | $expected = json_encode($expected); 127 | $this->assertTextEquals($expected, $result); 128 | } 129 | 130 | /** 131 | * @return void 132 | */ 133 | public function testWithoutSubdir() { 134 | $Request = new ServerRequest(); 135 | $Response = new Response(); 136 | $View = new AjaxView($Request, $Response); 137 | $View->setTemplatePath('Items'); 138 | $View->setSubDir(''); 139 | $result = $View->render('index'); 140 | 141 | $response = $View->getResponse(); 142 | $this->assertSame('application/json', $response->getType()); 143 | $expected = ['error' => null, 'success' => null, 'content' => 'My Index Test ctp' . PHP_EOL]; 144 | $expected = json_encode($expected); 145 | $this->assertTextEquals($expected, $result); 146 | } 147 | 148 | } 149 | -------------------------------------------------------------------------------- /tests/config/routes.php: -------------------------------------------------------------------------------- 1 | scope('/', function (RouteBuilder $routes) { 11 | $routes->connect('/:controller', ['action' => 'index'], ['routeClass' => 'DashedRoute']); 12 | $routes->connect('/:controller/:action/*', [], ['routeClass' => 'DashedRoute']); 13 | }); 14 | --------------------------------------------------------------------------------