Skip to content

AI blog · DotApp PHP Framework 2.0

How to build JSON endpoints with Router in DotApp PHP Framework

A public JSON API is an ordinary Router::get / post whose controller returns Response::json($array, $code). That sets the body and Content-Type: application/json; charset=utf-8. Put the version in the path you register: $p . '/api/v1/items'. Do not invent a resource dispatcher from the URL. Shop code registers each verb itself. This article is a complete Shop JSON pair: list + show + create, named RAW, and when you still need crcCheck.

Must: Router + Response::json

Do not build Shop JSON on apiPoint

New Shop endpoints are explicit routes plus Response::json. Do not add Router::apiPoint or Controller::apiDispatch / api() to a module you are writing now. Those helpers exist in the kernel; they are not the public how-to for this framework.

The browser channel is a different contract: ajaxReply + parseReply, always after crcCheck(). Mixing them (JSON HTTP body into $dotapp().load() without parseReply) looks like a blank error.

Common mistakes

Wrong Right
Router::apiPoint + getItems / postItems invented from the path One Router::get / post per verb. Return Response::json
Answer $dotapp().load() with Response::json ajaxReply + parseReply. Channel: BE↔FE communication
Skip crcCheck() because “it is JSON” If the browser posted this URL through the channel, still crcCheck() once first
Middleware crcCheck() plus another in create() One call per request. Request lifecycle
Concatenate the id into SQL Named RAW WHERE id = :id plus ['id' => $id], or QueryBuilder bindings
$request->data()['title'] then persist $request->data(true)Request lifecycle

When to use JSON HTTP

Machine clients, mobile apps, and public read APIs that do not ride /assets/dotapp/dotapp.js. Shop UI tables that call $dotapp().load() stay on ajaxReplyAJAX lists with pagination. Routing: How routing works.

Complete Shop API

Files: app/modules/Shop/module.init.php (routes) and app/modules/Shop/Controllers/Api.php. Scaffold the controller with DotApper. Tables: shop_items.


$p = Config::module('Shop', 'prefix');
Router::get($p . '/api/v1/items', 'Shop:Api@list!', Router::STATIC_ROUTE);
Router::get($p . '/api/v1/items/{id:i}', 'Shop:Api@show!');
Router::post($p . '/api/v1/items', 'Shop:Api@create!', Router::STATIC_ROUTE);
    

<?php
namespace Dotsystems\App\Modules\Shop\Controllers;

use Dotsystems\App\Parts\DB;
use Dotsystems\App\Parts\Logger;
use Dotsystems\App\Parts\Response;

class Api extends \Dotsystems\App\Parts\Controller
{
    public static function list($request)
    {
        $pageNum = (int) ($request->query()['page'] ?? 1);
        if ($pageNum < 1) {
            $pageNum = 1;
        }
        $page = DB::module('RAW')->q(function ($qb) {
            $qb->select(['id', 'title'])->from('shop_items')->orderBy('id', 'DESC');
        })->paginate(20, $pageNum);
        return Response::json(['status' => 1, 'page' => $page]);
    }

    public static function show($request)
    {
        $id = (int) ($request->matchData()['id'] ?? 0);
        $rows = DB::module('RAW')->q(function ($qb) use ($id) {
            $qb->raw('SELECT id, title FROM shop_items WHERE id = :id LIMIT 1', ['id' => $id]);
        })->all();
        $row = $rows[0] ?? null;
        if ($row === null) {
            return Response::json(['status' => 0, 'message' => 'Not found'], 404);
        }
        return Response::json(['status' => 1, 'item' => $row]);
    }

    public static function create($request)
    {
        $raw = $request->data(true);
        $title = trim((string) ($raw['title'] ?? ''));
        if ($title === '') {
            return Response::json(['status' => 0, 'message' => 'Title required'], 422);
        }
        $newId = null;
        DB::module('RAW')->q(function ($qb) use ($title) {
            $qb->raw(
                'INSERT INTO shop_items (title, created_at) VALUES (:title, :created_at)',
                ['title' => $title, 'created_at' => date('Y-m-d H:i:s')]
            );
        })->execute(
            function ($result, $db, $execution_data) use (&$newId) {
                $newId = $execution_data['insert_id'] ?? $db->inserted_id();
            },
            function ($error) {
                Logger::use()->error('api create failed', $error);
            }
        );
        if ($newId === null) {
            return Response::json(['status' => 0, 'message' => 'Save failed'], 500);
        }
        return Response::json(['status' => 1, 'id' => $newId], 201);
    }
}
    

paginate() returns an array with data, current_page, last_page, and friends — not a string of HTML. execute() without the error callback throws on failure. Always pass both callbacks. Query GET with query(). Route {id:i} with matchData(). Four failure styles: Error handling and return values.

Same URL, browser channel

If Shop JS posts this path with $dotapp().load() or <fo-rm>, it is not “plain JSON” anymore: unwrap with crcCheck() once, read $request->data(true)['data'], answer with ajaxReply. Do not also crcCheck() in a global before-hook — Request lifecycle. HTTP 400/403 never reach form .after() — hook .onError(). Request lifecycle.

FAQ

Where does the version number go?

In the path you register. There is no separate version helper you must call first.

Are JSON strings escaped for Unicode?

Default flags are JSON_UNESCAPED_UNICODE. Pass a third argument to Response::json if you need other flags.

Why not first() for show?

RAW first() on zero rows is unusable. ORM first() on empty is fatal. Use all() and $rows[0] ?? null.

I saw apiPoint in old docs

Leave it. New Shop JSON is Router + Response::json per verb.

Does Response::json set CORS?

No. Add headers on the Response if a browser from another origin must call this. Prefer same-origin Shop pages.

How do I protect it?

A before hook or named middleware that returns new Response(403, ...) or JSON 401. Middleware.

See also