Working CSRF AJAX in CakePHP 4 - looking for criticism!

This is a complete build of a running AJAX using CakePHP4 with CSRF and Form Tampering protection. It reverses a user entered string.

Create the ajax project: -
composer self-update && composer create-project --prefer-dist cakephp/app:4.* ajax

Edit config/app_local.php

...
		'username' => 'root',
		'password' => 'secret',
		'database' => 'cake_ajax',
...

Or whatever your database is.
For this demonstration I am using this table: -

DROP TABLE IF EXISTS `myajax`;
CREATE TABLE `myajax` (
  `myajax_id` int(11) NOT NULL AUTO_INCREMENT,
  `reverse_text` text,
  `solution_text` text,
  `created` datetime NOT NULL,
  `modified` datetime NOT NULL,
  PRIMARY KEY (`myajax_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

As CakePHP is ORM this is just a dummy table for filling in.

Edit config/bootstrap.php and add this line at the bottom of the file: -

Router::extensions(['json']);

Edit config/routes.php

<?php
...
//***You do not need to add this if using the latest build which loads CSRF in the src/Application.php
//Add this to the uses clauses
use Cake\Http\Middleware\CsrfProtectionMiddleware;
...
$routes->scope('/', function (RouteBuilder $builder) {
//Insert after the line above
	$builder->registerMiddleware('csrf', new CsrfProtectionMiddleware([
		'httpOnly' => true,
	]));
	$builder->applyMiddleware('csrf');
//***Done CSRF		
...
	$builder->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']);
//Replace the line above with this
	$builder->connect('/', ['controller' => 'MyAjax', 'action' => 'reverse']);
...		

The form protection / csrf protection relies on routing being consistent, be wary of how this is managed.

Edit src/Controller/AppController.php

...
//Uncomment this line that's near the bottom
	$this->loadComponent('FormProtection');
...
//Add this function at the bottom of the file
	public function beforeFilter(\Cake\Event\EventInterface $event)
	{
		parent::beforeFilter($event);
		$this->FormProtection->setConfig('unlockedActions', ['reverse']); //disable for the actual AJAX
	}
...		

Create src/Controller/MyAjaxController.php

<?php
declare(strict_types=1);

namespace App\Controller;

class MyAjaxController extends AppController
{
	public function reverse()
	{
//		$this->Authorization->skipAuthorization(); //Uncomment if needed
		$this->request->allowMethod(['get', 'put']);
		$myajax = $this->MyAjax->newEmptyEntity();
		if ($this->request->is(['get'])) //priming the form
		{
			$myajax->reverse_text = '!pirt dnuor'; //text to be reversed
		}
		if ($this->request->is(['ajax'])) //handling the ajax put call
		{
			$myajax = $this->MyAjax->patchEntity($myajax, $this->request->getData());
			$myajax->solution_text = strrev($myajax->reverse_text); //the data we generate
			$this->MyAjax->save($myajax); //for my purposes here I don't care if this fails
			$this->viewBuilder()->setOption('serialize', true); //echo'ing our result
		}
		$this->set(compact('myajax')); //returns both the original content, also the ajax primed data :)
	}
}

Create src/Model/Entity/MyAjax.php

<?php
declare(strict_types=1);

namespace App\Model\Entity;

use Cake\ORM\Entity;

/**
 * MyAjax Entity
 *
 * @property int myajax_id
 * @property string $reverse_text
 * @property string $solution_text
 */
class MyAjax extends Entity
{
	protected $_accessible = [
		'myajax_id' => true,
		'reverse_text' => true,
		'solution_text' => true,
	];    
}

Create src/Model/Table/MyAjaxTable.php

<?php
declare(strict_types=1);

namespace App\Model\Table;

use Cake\ORM\Table;

class MyAjaxTable extends Table
{
  
	public function initialize(array $config): void
	{
		parent::initialize($config);

		$this->setTable('myajax');
		$this->setDisplayField('myajax_id');
		$this->setPrimaryKey('myajax_id');

		$this->addBehavior('Timestamp');
	}

}

Create folder & file templates/MyAjax/reverse.php

<h2>Simple AJAX</h2>
<?= $this->Form->create($myajax, ['id' => 'frmMyAjax']) /*Form has tamper protection, ID needed for AJAX*/ ?>
<?= $this->Form->control('reverse_text', ['label' => 'Reverse text:']) ?>
<?= $this->Form->end() /*This "form" has no submit button, its just so I can group the AJAX call*/ ?> 
<!-- NOTE: This button is outside the form, it is not a submit -->
<?= $this->Form->button('Reverse text',
				['id' => 'btn-rev-text',
				 'data-rel' => $this->Url->build(['_ext' => 'json'])]) ?>
<div id="div-result" style="font-size: 150%;"></div>

<script src="//code.jquery.com/jquery-3.5.0.min.js"></script>
<script>
$.ajaxSetup({cache: false});
var $btnrevtext = $('#btn-rev-text');
var $result = $('#div-result');
var targeturl = $btnrevtext.data('rel');
$(function() {
  $btnrevtext.click(function() {
    $btnrevtext.blur(); //so stupid hover effect doesn't get stuck
    var data = $('#frmMyAjax').serialize(); //wrap up the request, using the form, also gets csrf
//    console.log(data); //uncomment to see what's getting sent - edit it to test form tamper & csrf
    $result.html('Pending &hellip;');
    $.ajax({
      type: 'put',
      url: targeturl,
      data: data,
			beforeSend: function(xhr) {
				xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
			},
      success: function(response) {
        if (response && response.myajax) {
          $result.text(response.myajax.solution_text); //sanitise, etc!
        } else {
          $result.text('Unexpected AJAX result :/');
          console.log(response);
        }
      },
      error: function(xhr, ajaxOptions, thrownError) {
        console.log(xhr, ajaxOptions, thrownError);
        $result.text('Crashed :(');
      }
    });
  });
});
</script>

If anything is not Cake proper please let me know / correct it.

Hope this helps / works!!

Cheers
Jonathan