API Reference

Scriptlog provides a RESTful API that allows external applications to interact with blog content. The API follows OpenAPI 3.0 specification and returns JSON responses.

Home / Documentation / API Reference

Authentication

The API supports three authentication methods:

API Key Authentication

HTTP headers
GET /api/v1/posts HTTP/1.1
Host: blogware.site
X-API-Key: your-api-key-here

Header only — query-string keys are not accepted (minimum 32 characters).

Bearer Token Authentication

HTTP headers
GET /api/v1/posts HTTP/1.1
Host: blogware.site
Authorization: Bearer your-bearer-token

Session / Cookie Authentication

POST /api/v1/media/upload authenticates via the admin panel session (scriptlog_session_login) or the scriptlog_auth cookie instead of an API key or Bearer token. The caller level must be one of administrator, manager, editor, author or contributor.

CSRF Protection

Session-authenticated write requests (POST/PUT/PATCH/DELETE sent with a cookie and without an X-API-Key/Authorization header) must include the token from GET /api/v1/csrf-token as the X-CSRF-Token header. Missing and invalid tokens return 403 with codes CSRF_MISSING and CSRF_INVALID. Requests authenticated with an API key or Bearer token are exempt.

Content-Type

Write requests must send Content-Type: application/json (application/x-www-form-urlencoded is also accepted). Any other content type returns 415 Unsupported Media Type. POST /media/upload is the multipart/form-data exception.

Authentication Requirements

Endpoint Type Authentication Required
Read (GET, QUERY) - Public contentNo
Create/Update/Partial Update/Delete (POST/PUT/PATCH/DELETE)Yes
POST /media/uploadYes (admin session/cookie + CSRF)

API Information

Method Endpoint Auth Description
GET/api/v1/NoGet API metadata and available endpoints

Example Response

JSON response
{
  "success": true,
  "status": 200,
  "message": "Welcome to Blogware RESTful API",
  "data": {
    "name": "Blogware RESTful API",
    "version": "1.1.2",
    "description": "RESTful API for Blogware content management system",
    "base_url": "/api/v1",
    "authentication": {
      "type": "API Key or Bearer Token",
      "header": "X-API-Key or Authorization: Bearer <token>",
      "required": false
    }
  },
  "_links": {
    "self": { "href": "http://blogware.site/api/v1", "rel": "self", "type": "GET" },
    "posts": { "href": "http://blogware.site/api/v1/posts", "rel": "posts", "type": "GET" },
    "categories": { "href": "http://blogware.site/api/v1/categories", "rel": "categories", "type": "GET" },
    "comments": { "href": "http://blogware.site/api/v1/comments", "rel": "comments", "type": "GET" },
    "archives": { "href": "http://blogware.site/api/v1/archives", "rel": "archives", "type": "GET" },
    "search": { "href": "http://blogware.site/api/v1/search?q={query}", "rel": "search", "type": "GET", "templated": true },
    "gdpr": { "href": "http://blogware.site/api/v1/gdpr/consent", "rel": "gdpr", "type": "GET" },
    "languages": { "href": "http://blogware.site/api/v1/languages", "rel": "languages", "type": "GET" },
    "translations": { "href": "http://blogware.site/api/v1/translations/en", "rel": "translations", "type": "GET" },
    "media": { "href": "http://blogware.site/api/v1/media/upload", "rel": "media", "type": "POST" },
    "openapi": { "href": "http://blogware.site/api/v1/openapi.json", "rel": "service-desc", "type": "application/json" }
  }
}

Permission Levels

Level Create Posts Edit Posts Delete Posts Manage Categories Moderate Comments
administrator
editor
author (own only)
subscriber

Rate Limiting

API requests are rate limited to ensure fair usage and prevent abuse. Rate limiting is applied per-client using, in priority order, the API key, the Bearer token, or the IP address as the identifier. QUERY requests count under the read limit.

Endpoint Type Limit Window
Read (GET, QUERY)60 requests60 seconds
Write (POST/PUT/DELETE/PATCH)20 requests60 seconds
Search (GET /search*)30 requests60 seconds (endpoint-local, on top of the read limit)

Configure via Settings → API in the admin panel (stored in tbl_settings as api_rate_limit_enabled, api_rate_limit_read, api_rate_limit_write).

Header Description
X-RateLimit-LimitMaximum requests allowed per window
X-RateLimit-RemainingRemaining requests in current window
X-RateLimit-ResetUnix timestamp when the rate limit resets
Retry-AfterSeconds to wait before retrying (only on 429 responses)
If you exceed the rate limit, you'll receive a 429 Too Many Requests response.

Posts API

Method Endpoint Auth Description
GET/api/v1/postsNoList published posts
GET/api/v1/posts/{id}NoGet single post
GET/api/v1/posts/{id}/commentsNoGet post comments
POST/api/v1/postsYesCreate post
PUT/api/v1/posts/{id}YesUpdate post
PATCH/api/v1/posts/{id}YesPartially update post
DELETE/api/v1/posts/{id}YesDelete post

Query Parameters (List Posts)

Parameter Type Default Description
pageinteger1Page number
per_pageinteger10Items per page (max: 100)
sort_bystringIDSort field (ID, post_date, post_modified, post_title)
sort_orderstringDESCSort direction (ASC, DESC)

Path Parameters (Get Single Post)

Parameter Type Description
idintegerPost ID

Example Paginated Response

JSON response
{
  "success": true,
  "status": 200,
  "data": [...],
  "pagination": {
    "current_page": 1,
    "per_page": 10,
    "total_items": 50,
    "total_pages": 5,
    "has_next_page": true,
    "has_previous_page": false
  },
  "_links": {
    "self": { "href": "http://blogware.site/api/v1/posts?page=1", "rel": "self", "type": "GET" },
    "next": { "href": "http://blogware.site/api/v1/posts?page=2", "rel": "next", "type": "GET" },
    "last": { "href": "http://blogware.site/api/v1/posts?page=5", "rel": "last", "type": "GET" }
  }
}

Create Post - Request Body

JSON request
{
  "post_title": "My New Post",
  "post_content": "Full content of the post",
  "post_summary": "Optional summary",
  "post_status": "draft",
  "post_visibility": "public",
  "post_tags": "php, api",
  "comment_status": "open",
  "topics": [1, 2]
}
Required Fields
  • post_title (string)
  • post_content (string)
Optional Fields
  • post_summary (string)
  • post_status (string: "publish", "draft")
  • post_visibility (string: "public", "private", "protected")
  • post_tags (string, comma-separated)
  • comment_status (string: "open", "closed")
  • topics (array of integers)

Query Parameters (Get Comments for Post)

Parameter Type Default Description
pageinteger1Page number
per_pageinteger10Items per page

Categories API

Method Endpoint Auth Description
GET/api/v1/categoriesNoList categories
GET/api/v1/categories/{id}NoGet category
GET/api/v1/categories/{id}/postsNoGet posts in category
POST/api/v1/categoriesYesCreate category
PUT/api/v1/categories/{id}YesUpdate category
PATCH/api/v1/categories/{id}YesPartially update category
DELETE/api/v1/categories/{id}YesDelete category

Query Parameters (List Categories)

Parameter Type Default Description
pageinteger1Page number
per_pageinteger10Items per page
sort_bystringIDSort field
sort_orderstringDESCSort direction

Example Response (List Categories)

JSON response
{
  "success": true,
  "status": 200,
  "data": [
    {
      "id": 1,
      "title": "Technology",
      "slug": "technology",
      "status": "Y",
      "post_count": 15,
      "url": "http://blogware.site/category/technology",
      "_links": {
        "self": { "href": "http://blogware.site/api/v1/categories/1", "rel": "self", "type": "GET" },
        "posts": { "href": "http://blogware.site/api/v1/categories/1/posts", "rel": "posts", "type": "GET" },
        "canonical": { "href": "http://blogware.site/category/technology", "rel": "canonical", "type": "text/html" },
        "collection": { "href": "http://blogware.site/api/v1/categories", "rel": "collection", "type": "GET" }
      }
    }
  ],
  "pagination": {...},
  "_links": { ... }
}

Create Category - Request Body

JSON request
{
  "topic_title": "Category Name",
  "topic_status": "Y"
}

Comments API

Method Endpoint Auth Description
GET/api/v1/commentsNoList approved comments
GET/api/v1/comments/{id}NoGet comment
POST/api/v1/commentsNoSubmit comment
PUT/api/v1/comments/{id}YesUpdate comment
PATCH/api/v1/comments/{id}YesPartially update comment
DELETE/api/v1/comments/{id}YesDelete comment

Query Parameters (List Comments)

Parameter Type Description
post_idintegerFilter by post ID
pageintegerPage number
per_pageintegerItems per page
sort_bystringSort field
sort_orderstringSort direction

Create Comment - Request Body

JSON request
{
  "comment_author_name": "John Doe",
  "comment_author_email": "[email protected]",
  "comment_content": "Great article!",
  "comment_post_id": 1,
  "comment_parent_id": 0
}
Note: Comments are submitted with 'pending' status for moderation.

Archives API

Method Endpoint Auth Description
GET/api/v1/archivesNoList archive dates
GET/api/v1/archives/{year}NoPosts from year
GET/api/v1/archives/{year}/{month}NoPosts from month

Example Response (List Archives)

JSON response
{
  "success": true,
  "status": 200,
  "data": {
    "archives": [
      {
        "year": 2024,
        "months": [
          {
            "month": 6,
            "month_name": "June",
            "post_count": 5
          }
        ],
        "total_posts": 25
      }
    ],
    "total_years": 3
  },
  "_links": {
    "self": { "href": "http://blogware.site/api/v1", "rel": "self", "type": "GET" },
    "posts": { "href": "http://blogware.site/api/v1/posts", "rel": "posts", "type": "GET" },
    "archives": { "href": "http://blogware.site/api/v1/archives", "rel": "archives", "type": "GET" },
    "search": { "href": "http://blogware.site/api/v1/search?q={query}", "rel": "search", "type": "GET", "templated": true },
    "openapi": { "href": "http://blogware.site/api/v1/openapi.json", "rel": "service-desc", "type": "application/json" }
  }
}

Path Parameters

Parameter Type Description
yearintegerYear (e.g., 2024)
monthintegerMonth (1-12)

Search API

Method Endpoint Auth Description
GET/api/v1/searchNoSearch all content (posts + pages)
GET/api/v1/search/postsNoSearch posts only
GET/api/v1/search/pagesNoSearch pages only

Search Parameters

Parameter Type Required Description
qstringYesSearch keyword (alias keyword, min 2, max 100 chars)
typestringNoall, posts, or pages (default: all)
pageintegerNoPage number (default: 1)
per_pageintegerNoItems per page (default: 10)
A non-empty search_verify parameter (honeypot) is rejected with 403. Besides the global read limit, an endpoint-local limit of 30 requests/minute applies (429).

Example Request

HTTP request
GET /api/v1/search?q=cicero&type=all

Protected Posts API

Method Endpoint Auth Description
POST/api/v1/posts/{id}/unlockNoUnlock password-protected post
POST/api/v1/posts/{id}/verifyNoVerify password for protected post

Unlock Post - Request Body

JSON request
{
  "password": "post-password"
}

Returns the sanitized decrypted content (data.content) on success and stores the password in the session. 401 Unauthorized on wrong password, 429 after too many failed attempts (per-post throttle). POST /posts/{id}/verify is the lightweight check that returns { "valid": true } without content.

Languages API

Method Endpoint Auth Description
GET/api/v1/languagesNoList active languages
GET/api/v1/languages/activeNoList active languages
GET/api/v1/languages/defaultNoGet default language
GET/api/v1/languages/{code}NoGet single language
POST/api/v1/languagesYesCreate language
PUT/api/v1/languages/{code}YesUpdate language
PATCH/api/v1/languages/{code}YesPartially update language
PUT/api/v1/languages/{code}/defaultYesSet as default language
PATCH/api/v1/languages/{code}/defaultYesSet as default language
DELETE/api/v1/languages/{code}YesDelete language

Create Language - Request Body

JSON request
{
  "lang_code": "de",
  "lang_name": "German",
  "lang_native": "Deutsch",
  "lang_locale": "de_DE",
  "lang_direction": "ltr",
  "lang_is_default": false
}

Required: lang_code, lang_name, lang_native. Optional: lang_locale, lang_direction (ltr/rtl), lang_sort, lang_is_default.

Translations API

Method Endpoint Auth Description
GET/api/v1/translations/{code}NoList translations for language
GET/api/v1/translations/{code}/{key}NoGet single translation
GET/api/v1/translations/{code}/exportNoExport translations as key-value map
POST/api/v1/translations/{code}YesCreate translation
POST/api/v1/translations/{code}/importYesBulk import translations
POST/api/v1/translations/{code}/cacheYesClear translation cache
PUT/api/v1/translations/{id}YesUpdate translation
PATCH/api/v1/translations/{id}YesPartially update translation
DELETE/api/v1/translations/{id}YesDelete translation

Create Translation - Request Body

JSON request
{
  "lang_code": "en",
  "translation_key": "nav.dashboard",
  "translation_value": "Dashboard",
  "translation_context": "frontend",
  "is_html": false
}

Required: lang_code, translation_key, translation_value. Optional: translation_context, is_html.

Import Translations - Request Body

JSON request
{
  "nav.dashboard": "Dashboard",
  "nav.posts": "Posts",
  "nav.settings": "Settings"
}

The request body is the key-value map itself (no wrapper object). Existing keys are updated; new keys are inserted.

GDPR API

Method Endpoint Auth Description
POST/api/v1/gdpr/consentNoSubmit cookie consent
GET/api/v1/gdpr/consentNoGet consent status

Submit Consent - Request Body

JSON request
{
  "status": "accepted"
}

status must be accepted or rejected, otherwise 400 (INVALID_STATUS). The decision is recorded with the client IP address and user agent. GET /api/v1/gdpr/consent?type=cookie returns { "consent_given": true, "consent_type": "cookie" }.

Health Check

Method Endpoint Auth Description
GET/api/v1/healthNoAPI health status for monitoring

Example Response

JSON response
{
  "success": true,
  "status": 200,
  "message": "API is operational",
  "data": {
    "status": "healthy",
    "timestamp": "2026-07-30 12:00:00",
    "php_version": "8.1.28"
  }
}

CSRF Token

Method Endpoint Auth Description
GET/api/v1/csrf-tokenNoGet CSRF token for write operations

Media Upload

Method Endpoint Auth Description
POST/api/v1/media/uploadYesUpload image (Summernote editor)

Request: multipart/form-data with field image (required) and optional post_id (links the image to a post). Supported types, checked by real MIME type: JPEG, PNG, GIF, WebP, BMP. Max size: 5MB. Auth: admin session or scriptlog_auth cookie with level administrator, manager, editor, author or contributor, plus the X-CSRF-Token header. Returns 201 with { url, filename, media_id, post_id }.

Query API (RFC 10008)

Method Endpoint Auth Description
QUERY/api/v1/queryNoQuery posts + pages (JSON body)
QUERY/api/v1/query/postsNoQuery posts only
QUERY/api/v1/query/pagesNoQuery pages only

The QUERY method (RFC 10008) is a safe, idempotent alternative to GET for complex queries. The query parameters are sent in the request body as JSON. Responses carry an Accept-Query: application/json header and count under the read rate limit. An empty body — or an empty keyword with type: all — returns 400.

JSON request body
{
  "type": "all",
  "q": "search keyword"
}

Query Parameters

All list endpoints support the following parameters:

Parameter Type Default Description
pageinteger1Page number
per_pageinteger10Items per page (max: 100)
sort_bystringIDSort field
sort_orderstringDESCSort direction (ASC/DESC)

Response Format

Success Response

JSON response
{
  "success": true,
  "status": 200,
  "message": "Operation description",
  "data": { ... }
}

Created Response

JSON response
{
  "success": true,
  "status": 201,
  "message": "Resource created",
  "data": {
    "id": 42
  }
}

Includes Location header with the resource URL.

No Content Response

Used for post, category and comment DELETE operations. Status 204 with empty body. Language and translation deletes instead return 200 with a null data payload.

Paginated Response

JSON response
{
  "success": true,
  "status": 200,
  "data": [...],
  "pagination": {
    "current_page": 1,
    "per_page": 10,
    "total_items": 50,
    "total_pages": 5,
    "has_next_page": true,
    "has_previous_page": false
  },
  "_links": {
    "self": { "href": "http://blogware.site/api/v1/posts?page=1", "rel": "self", "type": "GET" },
    "first": { "href": "http://blogware.site/api/v1/posts?page=1", "rel": "first", "type": "GET" },
    "next": { "href": "http://blogware.site/api/v1/posts?page=2", "rel": "next", "type": "GET" },
    "last": { "href": "http://blogware.site/api/v1/posts?page=5", "rel": "last", "type": "GET" }
  }
}

Error Response

JSON error
{
  "success": false,
  "status": 400,
  "error": {
    "code": "BAD_REQUEST",
    "message": "Error description"
  }
}

HATEOAS (Hypermedia as the Engine of Application State)

All API responses include HATEOAS links following RFC 5988 (Web Linking). This allows clients to discover available actions dynamically without hardcoding URLs.

Common Link Relations

Relation Description
selfThe current resource URL
collectionThe parent collection URL
firstFirst page of paginated results
prevPrevious page of paginated results
nextNext page of paginated results
lastLast page of paginated results
canonicalThe canonical HTML URL for the resource
commentsComments for a post
postThe parent post for a comment
postsPosts in a category
yearYear archive for a month
searchSearch endpoint (templated URL)
service-descOpenAPI specification URL

Example Single Resource with HATEOAS

JSON response
{
  "success": true,
  "status": 200,
  "data": {
    "id": 1,
    "title": "My First Blog Post",
    "slug": "my-first-blog-post"
  },
  "_links": {
    "self": { "href": "http://blogware.site/api/v1/posts/1", "rel": "self", "type": "GET" },
    "comments": { "href": "http://blogware.site/api/v1/posts/1/comments", "rel": "comments", "type": "GET" },
    "canonical": { "href": "http://blogware.site/post/1/my-first-blog-post", "rel": "canonical", "type": "text/html" },
    "collection": { "href": "http://blogware.site/api/v1/posts", "rel": "collection", "type": "GET" }
  }
}

HTTP Status Codes

Code Meaning
200OK
201Created
204No Content
304Not Modified (conditional GET)
400Bad Request
401Unauthorized
403Forbidden
404Not Found
405Method Not Allowed
406Not Acceptable
409Conflict
415Unsupported Media Type
422Unprocessable Entity
429Too Many Requests
500Internal Server Error
503Service Unavailable

Error Codes

Code Description
BAD_REQUESTInvalid request parameters
UNAUTHORIZEDAuthentication required
FORBIDDENInsufficient permissions
NOT_FOUNDResource not found
METHOD_NOT_ALLOWEDHTTP method not supported for this endpoint
NOT_ACCEPTABLERequested media type not supported (406)
UNSUPPORTED_MEDIA_TYPEContent-Type not supported (415)
CONFLICTResource already exists
VALIDATION_ERRORValidation failed (422, with per-field details)
CSRF_MISSING / CSRF_INVALIDMissing or invalid X-CSRF-Token header (403)
INVALID_STATUSBad GDPR consent status (must be accepted/rejected)
SEARCH_ERRORSearch backend failure (500)
FETCH_ERROR / CREATE_ERROR / UPDATE_ERROR / DELETE_ERRORController operation failures (500)
EXPORT_ERROR / IMPORT_ERROR / CACHE_ERRORTranslation export/import/cache failures (500)
CONSENT_FAILED / CONSENT_STATUS_FAILEDGDPR write/read failures (500)
RATE_LIMIT_EXCEEDEDToo many requests (429)
INTERNAL_SERVER_ERRORServer error
SERVICE_UNAVAILABLEService temporarily unavailable

SDK Examples

JavaScript / Fetch

JavaScript example.js
const baseUrl = 'http://blogware.site/api/v1';

// Get posts
const response = await fetch(`${baseUrl}/posts`);
const data = await response.json();

// Get single post
const post = await fetch(`${baseUrl}/posts/1`);

// Create comment (no auth required)
const comment = await fetch(`${baseUrl}/comments`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    comment_author_name: 'John Doe',
    comment_author_email: '[email protected]',
    comment_content: 'Great article!',
    comment_post_id: 1
  })
});

PHP

PHP example.php
$baseUrl = 'http://blogware.site/api/v1';

// Get posts
$response = file_get_contents($baseUrl . '/posts');
$posts = json_decode($response, true);

// Get posts with authentication
$context = stream_context_create([
  'http' => [
    'header' => "X-API-Key: your-api-key\r\n"
  ]
]);
$response = file_get_contents($baseUrl . '/posts', false, $context);

Python

Python example.py
import requests

base_url = 'http://blogware.site/api/v1'

# Get posts
response = requests.get(f'{base_url}/posts')
posts = response.json()

# Get posts with authentication
headers = {'X-API-Key': 'your-api-key'}
response = requests.get(f'{base_url}/posts', headers=headers)

# Create comment
data = {
    'comment_author_name': 'John Doe',
    'comment_author_email': '[email protected]',
    'comment_content': 'Great article!',
    'comment_post_id': 1
}
response = requests.post(f'{base_url}/comments', json=data)

cURL

BASH commands
# Get posts
curl http://blogware.site/api/v1/posts

# Get posts with authentication
curl -H "X-API-Key: your-api-key" http://blogware.site/api/v1/posts

# Create comment
curl -X POST http://blogware.site/api/v1/comments \
  -H "Content-Type: application/json" \
  -d '{
    "comment_author_name": "John Doe",
    "comment_author_email": "[email protected]",
    "comment_content": "Great article!",
    "comment_post_id": 1
  }'

OpenAPI Specification

The complete OpenAPI 3.0 specification is available via the dynamic endpoint or as static files:

Dynamic Endpoint (Recommended)

Access the live specification at GET /api/v1/openapi.json — it uses a relative server URL (/api/v1) that resolves on any host; only the logo URL is substituted from the app configuration.

Static Files

Use these files to generate client SDKs, validate API responses, import into API testing tools (Postman, Swagger UI), or auto-generate documentation.

Using with Swagger UI

To view the API documentation in Swagger UI:

  1. Copy the API_OPENAPI.json file to a web server
  2. Navigate to Swagger Editor
  3. Paste the JSON content
  4. Explore the interactive API documentation

Using with Postman

To import into Postman:

  1. Open Postman
  2. Click Import
  3. Select "Import from link"
  4. Enter: http://blogware.site/api/v1/openapi.json

Creating API Controllers

All API controllers follow a consistent pattern: Controller → Service → DAO → Database, with DTOs for response formatting.

Step 1: Create DTO

PHP lib/dto/api/MyResourceApiDto.php
<?php
namespace Scriptlog\Dto\Api;

use Scriptlog\Core\ApiHateoas;

class MyResourceApiDto
{
    public static function transform(array $resource, string $appUrl, ApiHateoas $hateoas): array
    {
        return [
            'id' => (int)$resource['id'],
            'title' => $resource['title'],
            'slug' => $resource['slug'],
            'url' => $appUrl . '/resource/' . $resource['id'] . '/' . $resource['slug'],
            'date' => $resource['created_at'],
            '_links' => $hateoas->rootLinks()
        ];
    }

    public static function transformCollection(array $resources, string $appUrl, ApiHateoas $hateoas): array
    {
        return array_map(function ($r) use ($appUrl, $hateoas) {
            return self::transform($r, $appUrl, $hateoas);
        }, $resources);
    }
}

Step 2: Create DAO

PHP lib/dao/MyResourceDao.php
<?php
namespace Scriptlog\Dao;

class MyResourceDao
{
    private $db;

    public function __construct($db)
    {
        $this->db = $db;
    }

    public function findAll(int $perPage, int $offset): array
    {
        // Name columns explicitly - never SELECT * in production code
        $stmt = $this->db->prepare(
            "SELECT id, title, content, created_at FROM tbl_resources ORDER BY id DESC LIMIT ? OFFSET ?"
        );
        $stmt->execute([$perPage, $offset]);
        return $stmt->fetchAll(\PDO::FETCH_ASSOC);
    }

    public function count(): int
    {
        return (int)$this->db->query("SELECT COUNT(*) FROM tbl_resources")->fetchColumn();
    }

    public function findById(int $id): ?array
    {
        $stmt = $this->db->prepare("SELECT id, title, content, created_at FROM tbl_resources WHERE id = ?");
        $stmt->execute([$id]);
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
        return $row ?: null;
    }

    public function create(array $data): int
    {
        $stmt = $this->db->prepare(
            "INSERT INTO tbl_resources (title, content) VALUES (?, ?)"
        );
        $stmt->execute([$data['title'], $data['content']]);
        return (int)$this->db->lastInsertId();
    }
}

Step 3: Create Service

PHP lib/service/MyResourceService.php
<?php
namespace Scriptlog\Service;

class MyResourceService
{
    private $resourceDao;

    public function __construct($resourceDao)
    {
        $this->resourceDao = $resourceDao;
    }

    public function getPaginatedResources(int $page, int $perPage): array
    {
        $offset = ($page - 1) * $perPage;
        return [
            'items' => $this->resourceDao->findAll($perPage, $offset),
            'total' => $this->resourceDao->count()
        ];
    }

    public function getResource(int $id): ?array
    {
        return $this->resourceDao->findById($id);
    }

    public function createResource(array $data): int
    {
        return $this->resourceDao->create($data);
    }
}

Step 4: Create Controller

PHP lib/controller/api/MyResourceApiController.php
<?php
namespace Scriptlog\Controller\Api;

use Scriptlog\Core\ApiResponse;
use Scriptlog\Dto\Api\MyResourceApiDto;

class MyResourceApiController extends \Scriptlog\Controller\ApiController
{
    private $resourceService;
    private $hateoas;

    public function __construct($resourceService)
    {
        // Public by default: set requiresAuth BEFORE parent::__construct(),
        // which is where authenticate() runs. Setting it later inside an
        // action has no effect on the constructor's 401 check.
        $this->requiresAuth = false;

        parent::__construct();
        $this->resourceService = $resourceService;
        $this->hateoas = new \Scriptlog\Core\ApiHateoas();
    }

    public function index($params = [])
    {
        $pagination = $this->getPagination($params);

        try {
            $result = $this->resourceService->getPaginatedResources(
                $pagination['page'],
                $pagination['per_page']
            );
            $transformed = MyResourceApiDto::transformCollection(
                $result['items'],
                $this->getAppUrl(),
                $this->hateoas
            );
            ApiResponse::paginated(
                $transformed,
                $pagination['page'],
                $pagination['per_page'],
                $result['total']
            );
        } catch (\Throwable $e) {
            ApiResponse::error($e->getMessage(), 500, 'FETCH_ERROR');
        }
    }

    public function show($params = [])
    {
        $id = isset($params['id']) ? (int)$params['id'] : 0;

        if (!$id) {
            ApiResponse::badRequest('Resource ID is required');
            return;
        }

        $resource = $this->resourceService->getResource($id);

        if (!$resource) {
            ApiResponse::notFound('Resource not found');
            return;
        }

        ApiResponse::success(
            MyResourceApiDto::transform($resource, $this->getAppUrl(), $this->hateoas)
        );
    }

    public function store($params = [])
    {
        $this->requiresAuth = true;

        if (!$this->hasPermission(['administrator', 'editor'])) {
            ApiResponse::forbidden('Permission denied');
            return;
        }

        $errors = $this->validateRequired($this->requestData, ['title', 'content']);

        if ($errors) {
            ApiResponse::unprocessableEntity('Validation failed', $errors);
            return;
        }

        $id = $this->resourceService->createResource($this->requestData);
        ApiResponse::created(['id' => $id], 'Resource created');
    }
}

Step 5: Register Routes

PHP api/index.php
$router->get('resources', 'MyResourceApiController@index');
$router->get('resources/(?P<id>[0-9]+)', 'MyResourceApiController@show');
$router->post('resources', 'MyResourceApiController@store');
$router->put('resources/(?P<id>[0-9]+)', 'MyResourceApiController@update');
$router->patch('resources/(?P<id>[0-9]+)', 'MyResourceApiController@update');
$router->delete('resources/(?P<id>[0-9]+)', 'MyResourceApiController@destroy');

Support

For issues and questions: