Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
CakePHPDevelopmentPHPTutorial

How to build a JSON REST API with CakePHP 5.4

A step-by-step CakePHP 5.4 tutorial: resource routes under an /api prefix, JsonView serialisation, bounded pagination, 201/204/422 status codes, JSON error bodies, CSRF handling for stateless clients and an integration test.

Cloudsoft Zone

Updated 9 min read

Code editor window showing PHP source code with syntax highlighting on a dark background
Photograph: Yuri Samoilov / CC BY 2.0, via Wikimedia Commons
In this article

Many CakePHP applications start life as server-rendered sites and later need an API: for a mobile app, a single-page front end, or a partner integration. CakePHP 5 already ships most of what you need for this. Resource routes map HTTP verbs to controller actions, JsonView turns view variables into JSON, and the request body parser reads JSON payloads into $this->request->getData().

This tutorial builds a small, versioned JSON API for a products table on CakePHP 5.4, the current minor release in October 2026. You will create the table, connect routes under an /api prefix, write a controller that returns proper status codes (201, 204, 404 and 422), keep pagination bounded, return JSON error bodies, and test the result with curl and an integration test.

Everything below follows the official CakePHP 5 book and the cakephp/app skeleton as they stood on 7 October 2026. Authentication is deliberately left out so the core pattern stays clear; the last section points to the official plugin you should add before exposing write endpoints.

What you need

CakePHP 5.4 requires PHP 8.2 or later (PHP 8.5 is supported) with the mbstring, intl, pdo and simplexml extensions, plus Composer. Supported databases include MySQL 5.7+, MariaDB 10.1+, PostgreSQL 9.6+, SQL Server 2012+ and SQLite. Create a fresh application with the official skeleton:

Shell
composer create-project --prefer-dist cakephp/app:~5.4 product_api
cd product_api
bin/cake server

The skeleton pins cakephp/cakephp to 5.4.*, which accepts patch releases only. At the time of writing the latest patch was 5.4.3, released on 3 October 2026. Set your database credentials in config/app_local.php (or the DATABASE_URL environment variable) before continuing.

Create the table and the model

The Migrations plugin (version 5.x for CakePHP 5) is installed with the skeleton. Create a migration file at config/Migrations/20261007000000_CreateProducts.php:

PHP
<?php
declare(strict_types=1);

use Migrations\BaseMigration;

class CreateProducts extends BaseMigration
{
    public function change(): void
    {
        $this->table('products')
            ->addColumn('name', 'string', ['limit' => 150, 'null' => false])
            ->addColumn('sku', 'string', ['limit' => 64, 'null' => false])
            ->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2, 'null' => false])
            ->addColumn('created', 'datetime', ['null' => true])
            ->addColumn('modified', 'datetime', ['null' => true])
            ->addIndex(['sku'], ['unique' => true])
            ->create();
    }
}

Run it, then let Bake generate the table and entity classes:

Shell
bin/cake migrations migrate
bin/cake bake model Products

Bake reads the schema and writes src/Model/Table/ProductsTable.php with validation rules and the Timestamp behavior (because of the created and modified columns), and src/Model/Entity/Product.php with an $_accessible list. Review both. The $_accessible list is your first line of defence against mass assignment: only fields set to true there can be filled from request data. Make sure id is not accessible, and add a uniqueness rule for sku in buildRules() if Bake did not.

Diagram of a client sending a request to a server and receiving a response
The request-response cycle that every REST endpoint follows. Diagram: Lubaochuan, CC BY-SA 4.0.

Connect resource routes under an API prefix

A routing prefix keeps API controllers in their own namespace (App\Controller\Api) and gives every URL an /api path segment. Inside it, resources() connects the six conventional REST routes. Add this to config/routes.php, inside the returned closure and before the default / scope:

PHP
$routes->prefix('Api', function (RouteBuilder $builder): void {
    $builder->setExtensions(['json']);
    $builder->resources('Products');
});

That produces the following routes. Because the json extension is enabled, each URL also works with a .json suffix; without the suffix, CakePHP chooses the format from the Accept header.

MethodURLActionSuccess status in this tutorial
GET/api/productsindex()200
GET/api/products/{id}view()200
POST/api/productsadd()201
PUT / PATCH/api/products/{id}edit()200
DELETE/api/products/{id}delete()204

By default the {id} element only matches integers or UUIDs. If you use other identifiers, pass an id pattern to resources(). You can also limit the routes with the only option, for example ['only' => ['index', 'view']] for a read-only API.

Parsing JSON bodies and CSRF

The skeleton's src/Application.php already adds BodyParserMiddleware, so a request sent with Content-Type: application/json is decoded into $this->request->getData(). It also adds the cookie-based CsrfProtectionMiddleware to every request, which would reject JSON POSTs from API clients that have no CSRF cookie.

The CakePHP security guide says CSRF protection is only needed for routes that rely on cookies or sessions; stateless API requests authenticated by a token are not affected by CSRF. If your API will use token authentication, skip the check for the Api prefix. Routing middleware runs before the CSRF middleware in the skeleton, so the prefix is available:

PHP
// src/Application.php, inside middleware()
$csrf = new CsrfProtectionMiddleware(['httponly' => true]);
$csrf->skipCheckCallback(function ($request) {
    return $request->getParam('prefix') === 'Api';
});

$middlewareQueue
    ->add(new ErrorHandlerMiddleware(Configure::read('Error'), $this))
    ->add(new HostHeaderMiddleware())
    ->add(new AssetMiddleware(['cacheTime' => Configure::read('Asset.cacheTime')]))
    ->add(new RoutingMiddleware($this))
    ->add(new BodyParserMiddleware())
    ->add($csrf);

If instead your API is called by your own browser front end using the session cookie, keep CSRF on and send the token in the X-CSRF-Token header, as the CSRF guide describes.

Write the API controller

Create src/Controller/Api/ProductsController.php. The viewClasses() method tells CakePHP which views are available for content negotiation, and the serialize view option tells JsonView which variables to encode, so no template files are needed.

PHP
<?php
declare(strict_types=1);

namespace App\Controller\Api;

use App\Controller\AppController;
use Cake\Http\Response;
use Cake\View\JsonView;

class ProductsController extends AppController
{
    protected array $paginate = [
        'limit' => 20,
        'maxLimit' => 50,
        'order' => ['Products.id' => 'asc'],
        'sortableFields' => ['id', 'name', 'price', 'created'],
    ];

    public function viewClasses(): array
    {
        return [JsonView::class];
    }

    public function index(): void
    {
        $products = $this->paginate($this->Products->find());

        $this->set([
            'data' => $products,
            'meta' => [
                'page' => $products->currentPage(),
                'perPage' => $products->perPage(),
                'total' => $products->totalCount(),
                'pageCount' => $products->pageCount(),
            ],
        ]);
        $this->viewBuilder()->setOption('serialize', ['data', 'meta']);
    }

    public function view(string $id): void
    {
        $this->set('data', $this->Products->get($id));
        $this->viewBuilder()->setOption('serialize', ['data']);
    }

    public function add(): void
    {
        $this->request->allowMethod(['post']);
        $product = $this->Products->newEntity(
            $this->request->getData(),
            ['fields' => ['name', 'sku', 'price']],
        );
        $this->respondWithSave($product, 201);
    }

    public function edit(string $id): void
    {
        $this->request->allowMethod(['patch', 'put']);
        $product = $this->Products->patchEntity(
            $this->Products->get($id),
            $this->request->getData(),
            ['fields' => ['name', 'sku', 'price']],
        );
        $this->respondWithSave($product, 200);
    }

    public function delete(string $id): Response
    {
        $this->request->allowMethod(['delete']);
        $this->Products->deleteOrFail($this->Products->get($id));

        return $this->response->withStatus(204);
    }

    private function respondWithSave(\App\Model\Entity\Product $product, int $status): void
    {
        if ($this->Products->save($product)) {
            $this->response = $this->response->withStatus($status);
            $this->set('data', $product);
            $this->viewBuilder()->setOption('serialize', ['data']);

            return;
        }

        $this->response = $this->response->withStatus(422);
        $this->set('errors', $product->getErrors());
        $this->viewBuilder()->setOption('serialize', ['errors']);
    }
}

A few details are worth calling out:

  • Bounded pagination. paginate() reads page, limit, sort and direction from the query string. maxLimit caps limit (the default cap is 100), and sortableFields ignores sorts on any column you have not allowed, which protects you from slow sorts on unindexed columns. A page number beyond the last page raises a NotFoundException.
  • Paging metadata. paginate() returns a PaginatedResultSet. It serialises to a plain JSON array of records and exposes currentPage(), perPage(), totalCount() and pageCount() for the meta block.
  • An explicit field allowlist. The fields option on newEntity() and patchEntity() restricts which keys are marshalled for this endpoint, on top of the entity's $_accessible rules.
  • 404s for free. get() throws a record-not-found exception that CakePHP renders as a 404, and allowMethod() answers a wrong verb with 405.
  • Validation errors as 422. getErrors() returns errors keyed by field and rule, which front ends can map directly to form fields.

Return JSON for errors too

Uncaught exceptions are rendered by WebExceptionRenderer through an ErrorController. The renderer sets the message, url and code view variables and marks them for serialisation; in debug mode it adds more detail. The skeleton's App\Controller\ErrorController renders HTML templates, but CakePHP looks for a prefix-specific error controller first, so you can add one for the API:

PHP
<?php
declare(strict_types=1);

namespace App\Controller\Api;

use App\Controller\AppController;
use Cake\View\JsonView;

class ErrorController extends AppController
{
    public function initialize(): void
    {
        // Keep error rendering minimal: do not load components here.
    }

    public function viewClasses(): array
    {
        return [JsonView::class];
    }
}

With debug set to false, a request for a missing product now returns a 404 with a short JSON body such as {"message":"Not Found","url":"/api/products/999.json","code":404} instead of an HTML page. Test this with debug off, because debug mode shows the developer error page and extra fields.

Text editor showing a small JSON document with nested keys, strings and arrays
JSON is the format JsonView produces from the variables you mark for serialisation. Image: Dcbmariano, CC BY 4.0.

Try it with curl

Shell
# Create
curl -i -X POST http://localhost:8765/api/products.json \
  -H 'Content-Type: application/json' \
  -d '{"name":"USB-C dock","sku":"DOCK-01","price":"89.00"}'

# List, page 1, 10 per page, sorted by price
curl -i 'http://localhost:8765/api/products.json?limit=10&sort=price&direction=desc'

# Update one field
curl -i -X PATCH http://localhost:8765/api/products/1.json \
  -H 'Content-Type: application/json' -d '{"price":"79.00"}'

# Delete
curl -i -X DELETE http://localhost:8765/api/products/1.json

Send the same POST without a name and you should get a 422 response with an errors object. Ask for limit=500 and you will get 50 records at most.

Add an integration test

CakePHP's IntegrationTestTrait runs requests through the full middleware stack without a web server. A first test for the list endpoint might look like this (create a Products fixture with bin/cake bake fixture Products):

PHP
<?php
declare(strict_types=1);

namespace App\Test\TestCase\Controller\Api;

use Cake\TestSuite\IntegrationTestTrait;
use Cake\TestSuite\TestCase;

class ProductsControllerTest extends TestCase
{
    use IntegrationTestTrait;

    protected array $fixtures = ['app.Products'];

    public function testIndexReturnsJson(): void
    {
        $this->configRequest(['headers' => ['Accept' => 'application/json']]);
        $this->get('/api/products');

        $this->assertResponseOk();
        $this->assertContentType('application/json');
        $body = json_decode((string)$this->_response->getBody(), true);
        $this->assertArrayHasKey('meta', $body);
    }

    public function testCreateRejectsMissingName(): void
    {
        $this->configRequest(['headers' => [
            'Accept' => 'application/json',
            'Content-Type' => 'application/json',
        ]]);
        $this->post('/api/products', json_encode(['sku' => 'X-1', 'price' => '1.00']));

        $this->assertResponseCode(422);
    }
}

CakePHP 5 supports PHPUnit 11.5.3+ and 12.1.3+; the skeleton's composer.json also allows PHPUnit 13.

Before you expose it: a short checklist

  • Add authentication with the official cakephp/authentication plugin (version 4 is the one compatible with CakePHP 5) and authorization with cakephp/authorization. Apply them to the Api prefix only if your web routes use a different scheme.
  • Version the URL (/api/v1) by nesting prefixes or setting a path on the prefix, before the first external client depends on it.
  • Do not serialise entities with internal columns. Use the entity $_hidden property or a dedicated finder that selects only public fields.
  • For very large exports, CakePHP 5.4 adds JsonStreamResponse, which streams JSON or NDJSON from a query instead of building the whole payload in memory.
  • Turn debug off in production and set App.fullBaseUrl; the skeleton's host-header middleware refuses requests in production when it is missing.

Summary

A CakePHP 5.4 JSON API needs three pieces: resource routes inside an Api prefix, a controller that declares JsonView and uses the serialize option, and a deliberate decision about CSRF for stateless clients. Add bounded pagination, explicit field allowlists, a prefix-specific ErrorController and integration tests, and you have an API that behaves predictably for clients. From here, add authentication, then document the endpoints for the people who will consume them.

Frequently asked questions

  • Which PHP version does CakePHP 5.4 need?

    CakePHP 5.4 requires PHP 8.2 or later and supports PHP 8.5, according to the official installation guide. It also needs the mbstring, intl, pdo and simplexml extensions.

  • Do I need template files for JSON responses in CakePHP?

    No. If you declare JsonView in viewClasses() and set the serialize view option, CakePHP encodes those view variables directly. Use templates in templates/<Controller>/json/ only when you need to reshape data first.

  • Why do my JSON POST requests fail with a CSRF error?

    The application skeleton applies CsrfProtectionMiddleware to every request. For stateless API clients that authenticate with tokens, skip the check for the API prefix with skipCheckCallback(). If the API uses the session cookie, keep CSRF and send the token in the X-CSRF-Token header.

  • How do I stop clients requesting thousands of rows at once?

    Set maxLimit in the controller's $paginate settings. CakePHP reduces any larger limit parameter to that value; the default cap is 100.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. REST — CakePHP Book (5.x)
  2. Routing: RESTful routing and prefix routing — CakePHP Book (5.x)
  3. JSON and XML views — CakePHP Book (5.x)
  4. Pagination — CakePHP Book (5.x)
  5. CSRF Protection — CakePHP Book (5.x)
  6. Installation — CakePHP Book (5.x)
  7. Error and exception handling — CakePHP Book (5.x)
  8. CakePHP 5.4.3 release — CakePHP on GitHub, 2026-10-03

Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.

  • Plush elePHPant toy from Heroku, a version of the PHP community mascot, photographed close up

    Laravel

    Upgrading from Laravel 12 to Laravel 13: a practical guide

    Laravel 12 lost bug-fix support in August 2026. This guide covers the PHP 8.3 requirement, Composer changes, the CSRF middleware rename, cache and session hardening, low-impact changes that often bite, and a safe rollout plan.

    By Cloudsoft Zone