# ProcessWire JSON API
> Build structured JSON APIs for ProcessWire without boilerplate.
> Convention-based, extensible, and MIT licensed.
> Requires PHP 8.2+ and ProcessWire 3.0.173+.
> Install: composer require fokke/pw-json-api
# Overview
[ProcessWire](https://processwire.com) is a very flexible content management framework and CMS. In addition to building websites, it can be used as a headless CMS. My usual workflow is to use ProcessWire as a data backend, exposing JSON endpoints that are ready to be consumed by a Vue.js front-end application.
The problem is that there's no unified way to define these endpoints. One approach is to create a template for each endpoint type (such as `User`), create a controller file for each template, juggle URL segments, validate request methods, and so on. This leads to quite a lot of boilerplate code. The page hierarchy does not always reflect the endpoint structure, and things can get messy quickly.
Luckily, ProcessWire introduced [URL hooks](https://processwire.com/blog/posts/pw-3.0.173/) in version `3.0.173`. This enables a different approach for defining the API, completely decoupling the page tree from the endpoint hierarchy and keeping the page tree free of actual endpoints. With proper base path configuration, your JSON endpoints can coexist with the website itself.
Since we're using ProcessWire pages as the main data source, the library includes a versatile [Page parser](#processwire-page-parser) tool to convert entire page trees into structured data, ready to be served as an API response.
---
# Getting started
Please check that your environment meets the minimum system requirements:
- **PHP** >=8.2
- **ProcessWire** >=3.0.173
## Install the library
```console
composer require fokke/pw-json-api
```
::: tip AI-assisted development
The Composer package includes an `llms-full.txt` file with the complete documentation in a single file. Feed it to your AI coding assistant to get contextual help while building your API.
:::
## Create your first service
Create a new service file, such as `/site/services/HelloWorldService.php` with the following code:
```php
addEndpoint('/hello-world')->get(function ($args) {
return new Response([
'hello' => 'world',
'request_method' => $args->request->method,
'path' => $args->request->path,
]);
});
}
}
```
## Create an API instance
The library utilizes ProcessWire URL hooks to create listeners for your endpoints. Therefore, you must initialize the API instance either in the `/site/ready.php` or `/site/init.php` file.
```php
use PwJsonApi\Api;
// Consider auto-loading all the services
require_once './services/HelloWorldService.php';
// API instance — always set a base path to avoid collisions with PW pages
$api = new Api();
$api->setBasePath('/api');
// Services
$api->addService(new HelloWorldService());
// Run API
$api->run();
```
## Test the endpoint
Open the URL `/api/hello-world`. The server should respond with status code 200 and the following JSON:
```json
{
"data": {
"hello": "world",
"request_method": "GET",
"path": "/api/hello-world"
}
}
```
---
# Application lifecycle
This page provides a high-level overview of the full application lifecycle, from bootstrapping to response output. Each step links to a detailed page — this page does not repeat their content.
## Bootstrap
The bootstrap phase runs once when the module file is loaded. It sets up the API structure before any HTTP request is handled.
1. **Create an API instance** — `new Api()` creates the top-level container. [Read more about the API instance](#api-instance).
2. **Configure** — `configure()` sets options like `trailingSlashes` and `jsonFlags`. [Read more about configuration](#configuration).
3. **Set base path** — `setBasePath()` defines the root path prefix for all endpoints. [Read more about the base path](#base-path).
4. **Add services** — `addService()` registers services. Each service's `init()` method is called, which registers endpoints, hooks, and child services. [Read more about services](#services).
5. **Add hooks and plugins** — Request hooks and plugins can be added at the API, service, or endpoint level. [Read more about request hooks](#request-hooks). [Read more about plugins](#plugins).
6. **Run** — `run()` validates the configuration (no duplicate service names or endpoint paths), registers a [ProcessWire URL hook](https://processwire.com/docs/modules/hooks/#url-hooks-new-in-3-0-173) listener for each endpoint path, and locks the instance to prevent further mutations. [Read more about running the API](#running-the-api).
## Request handling
The request handling phase runs for each incoming HTTP request that matches a registered endpoint path.
1. **OPTIONS shortcut** — If the request method is `OPTIONS`, a `200` response with an `Allow` header is returned immediately. No hooks or handlers are executed. [Read more about endpoints](#endpoints).
2. **Request object** — A `Request` object is created containing the HTTP method, path, query parameters, headers, body, and files. [Read more about requests](#requests).
3. **Handler lookup** — The library looks up a handler for the request method. If no handler is registered, a `405` response with an `Allow` header is returned. [Read more about endpoints](#endpoints).
4. **Authentication** — If an authenticator is configured, it runs now. The closest level to the endpoint wins (endpoint > service > API). [Read more about authentication](#authentication-authorization).
5. **Authorization** — All authorization callbacks in the chain run in order: API → services → endpoint. [Read more about authorization](#authorization).
6. **Before hooks** — Before hooks are executed in order: API → service → endpoint. [Read more about hook execution order](#hook-execution-order).
7. **Handler execution** — The endpoint handler runs and returns a `Response`. [Read more about responses](#responses).
8. **After hooks** — After hooks are executed in order: endpoint → service → API. [Read more about hook execution order](#hook-execution-order).
9. **Error handling** — If an exception is thrown at any point, error hooks are executed and the exception is converted to a JSON response. [Read more about error handling](#error-handling). [Read more about error hooks](#error-hooks).
10. **JSON response** — The final response is encoded as JSON and sent to the client.
---
# API Instance
An API instance holds all the services. You can also define system-wide request hooks, which will apply to every endpoint. [Read more about hooks](#request-hooks).
```php
$api = new Api();
```
## Configuration
Use `configure()` to configure the main instance.
```php
$api->configure(function ($config) {
// Should endpoint paths end with a trailing slash?
// (null = no preference)
$config->trailingSlashes = null;
// Flags to pass to the json_encode function
// (default: JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
$config->jsonFlags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
// Optional: pretty-print JSON in debug mode
// if (wire()->config->debug) {
// $config->jsonFlags |= JSON_PRETTY_PRINT;
// }
});
```
## Base path
All endpoint paths will be prefixed with the base path. You can set a custom base path with `setBasePath()`. The default value is `/`.
::: warning
Always set a base path. Without one, endpoint paths may collide with ProcessWire page URLs — in which case the page takes priority and the endpoint becomes unreachable.
:::
```php
$api->setBasePath('/api');
```
## Adding a service
Call `addService()` to attach a service to the API.
```php
$api->addService(new HelloWorldService());
```
The newly added service can be accessed in an optional setup function. This can be used to reconfigure the service, add child services, define hooks, etc.
```php
$api->addService(new HelloWorldService(), function ($service) {
// Override the default base path
$service->setBasePath('/greet');
// Add a child service
$service->addService(new AnotherService());
});
```
## Running the API
Call `run()` to register all endpoint listeners and start handling requests. This should be called after all services and configuration have been set up.
```php
$api->run();
```
Under the hood, the method:
1. Iterates all registered services and their endpoints
2. Validates that there are no duplicate service names or endpoint paths
3. Registers a [ProcessWire URL hook](https://processwire.com/docs/modules/hooks/#url-hooks-new-in-3-0-173) listener for each endpoint path
::: tip
For a complete overview of the initialization and request handling sequence, see [Application lifecycle](#application-lifecycle).
:::
### Locking
After `run()` is called, the Api instance, all services, and all endpoints are **locked**. Locked objects reject structural mutations — this includes adding services, endpoints, plugins, hooks, and setting endpoint handlers.
All configuration must happen either in a service's `init()` method or in the `addService()` setup callback.
A `WireException` is thrown if a mutation is attempted after locking.
## Exception handling
Due to the nature of ProcessWire URL hooks, exceptions thrown in hook code cannot be caught in the main program flow. Use `handleException()` to define your own exception handler for other exception types, such as `WireException`.
You can access the following properties via the `$args` parameter of the handler function.
| Property | Type | Description |
| ----------- | ------------------------ | ---------------------------- |
| `exception` | `\Throwable` | Exception |
| `request` | `Request` | [Request object](#requests) |
| `user` | `\ProcessWire\User` | The current ProcessWire user |
| `event` | `\ProcessWire\HookEvent` | ProcessWire URL hook event |
| `endpoint` | `Endpoint` | Requested endpoint |
| `service` | `Service` | Requested service |
| `services` | `ServiceList` | List of all parent services |
| `api` | `Api` | API instance |
You need to return either a `Response` or an `ApiException` from the handler.
```php
$api->handleException(function ($args) {
// Handle WireExceptions
if ($args->exception instanceof WireException) {
return (new ApiException())->code(500)->with([
'message' => $args->exception->getMessage(),
]);
}
return (new ApiException())->code(400)->with([
'message' => $args->exception->getMessage(),
]);
});
```
## Multiple instances
You can create multiple API instances, each with its own configuration, services, and hooks. This can be useful for API versioning.
::: tip
To avoid path clashes, it's highly recommended to set unique base paths for all instances.
:::
```php
$v1 = new Api();
$v1->setBasePath('/v1');
$v1->run();
$v2 = new Api();
$v2->setBasePath('/v2');
$v2->run();
```
---
# Services
Services are used to group endpoints. Since services are classes, they can have properties and methods.
A service can define any number of [endpoints](#endpoints), and services may also contain child services, which will inherit hooks from the parent service.
You can also define service-wide request hooks, which will apply to every endpoint of the service and its child services. [Read more about request hooks](#request-hooks).
## Example service
Create a new service by extending the `Service` class. Override the `init()` method to define one or more endpoints with handlers for different request methods. [Read more about endpoints](#endpoints).
```php
setBasePath('/greet');
}
protected function init()
{
// Listen to the base path (/greet)
$this->addEndpoint('/')->get(function () {
return (new Response())->with([
'message' => 'Nothing to see here!',
]);
});
// Listen to path /greet/hello-world with GET handler
$this->addEndpoint('/hello-world')->get(function () {
return new Response($this->getData());
});
}
public function getData(): array
{
return [
'hello' => $this->word,
];
}
}
```
## Base path
Like the main instance, the service can also define its base path. Set the base path in the constructor so that [extending classes](#extending-a-service) can override it independently of the endpoint configuration.
```php
public function __construct()
{
parent::__construct();
$this->setBasePath('/greet');
}
```
## Child services
In many cases, a flat service tree is enough, but for larger APIs you can define child services to further categorize the endpoints. Child services will inherit all hooks from parent service(s), and the base paths of the parent services will apply.
Child services can be defined in the service `init()` method by calling `addService()`.
```php
// In init()
$this->addService(new AnotherService());
```
This relationship can also be defined on the fly while adding the service to the main instance, allowing you to nest services that normally would not be related to each other. [See example of adding a child service on the fly](#adding-a-service).
## Extending a service
Since services are classes, you can extend them to reuse or replace functionality. The `init()` method is intentionally separate from the constructor, giving the extending class full control over which endpoints are registered.
Set the base path in the constructor so it can be overridden independently of the endpoint configuration.
```php
class GreetingService extends Service
{
public function __construct()
{
parent::__construct();
$this->setBasePath('/greet');
}
protected function init()
{
$this->addEndpoint('/hello')->get(function () {
return new Response(['message' => 'Hello!']);
});
$this->addEndpoint('/goodbye')->get(function () {
return new Response(['message' => 'Goodbye!']);
});
}
}
```
### Keeping parent endpoints
An extending class can call `parent::init()` to keep the parent endpoints and add its own:
```php
class ExtendedGreetingService extends GreetingService
{
protected function init()
{
parent::init();
$this->addEndpoint('/hey')->get(function () {
return new Response(['message' => 'Hey!']);
});
}
}
```
### Replacing parent endpoints
Or it can skip `parent::init()` entirely to replace the endpoints:
```php
class ReplacedGreetingService extends GreetingService
{
protected function init()
{
// Only /howdy is registered — parent endpoints are not included
$this->addEndpoint('/howdy')->get(function () {
return new Response(['message' => 'Howdy!']);
});
}
}
```
## Accessing the ProcessWire API in the service
Use `wire` property to access ProcessWire API
```php
$page = $this->wire->pages->findOne('template=basic-page');
```
## Accessing the main instance in the service
A reference to the main instance will be injected into the `api` property of the service. You can use this to access methods and properties of other services.
::: warning
The main instance will be injected into the service when `run()` is called. Therefore, you cannot access `api` directly in the service `init()` method.
:::
```php
class HelloWorldService extends Service
{
protected function init()
{
// ❌ Trying to access API too early
$anotherService = $this->api->findService('AnotherService')
$this->addEndpoint('/use-another-service')->get(function () {
// ✅ This will work
$anotherService = $this->api->findService('AnotherService')
return new Response([
'hello' => 'world',
...($anotherService?->gimmeGimme() ?? []),
]);
});
}
}
```
---
# Endpoints
Under the hood, the endpoint path will be used to create a [ProcessWire URL hook](https://processwire.com/blog/posts/pw-3.0.173/#introducing-url-path-hooks). All URL hook features are supported.
Each endpoint listener must either return a [`Response`](#responses) object or throw an [`ApiException`](#error-handling) to halt the process and respond with an error.
::: info
By default, all the request methods are denied, except for `OPTIONS`. You must add listeners for each request method you want to allow. Requests with disallowed methods will be denied with a 405 response that includes an `Allow` header listing the permitted methods.
The `OPTIONS` method is always allowed for existing endpoints and will result in an empty response with a status code of `200`. The response includes an `Allow` header listing the permitted methods.
:::
The supported listeners are:
- `get()`
- `post()`
- `head()`
- `put()`
- `patch()`
- `delete()`
Like an API instance or service, an endpoint can also contain request hooks. [Read more about request hooks](#request-hooks).
## Example endpoint
Call `addEndpoint()` in the service `init()` method and pass the `path` you want to listen to.
```php
use PwJsonApi\ApiException;
```
```php
$this->addEndpoint('/user')
// Handle GET request
->get(function () {
return new Response([
'first_name' => 'Jerry',
'last_name' => 'Cotton',
]);
})
// Handle PUT request
->put(function ($args) {
$body = $args->request->body;
// Sanitize input
$firstName = $this->wire->sanitizer->text($body['first_name'] ?? '');
$lastName = $this->wire->sanitizer->text($body['last_name'] ?? '');
// Validate required fields
if (empty($firstName)) {
throw new ApiException('First name is required.');
}
if (empty($lastName)) {
throw new ApiException('Last name is required.');
}
// Save user details...
// Respond with updated data
return (new Response([
'first_name' => $firstName,
'last_name' => $lastName,
]))->with(['message' => 'User details saved successfully!']);
});
```
## Endpoint handler arguments
You can access the following properties via the `$args` parameter of the handler function.
| Property | Type | Description |
| --------- | ------------------------ | ---------------------------- |
| `request` | `Request` | [Request object](#requests) |
| `user` | `\ProcessWire\User` | The current ProcessWire user |
| `event` | `\ProcessWire\HookEvent` | ProcessWire URL hook event |
```php{1}
$this->addEndpoint('/test-request')->get(function ($args) {
return new Response([
'request_path' => $args->request->path,
'request_method' => $args->request->method,
]);
});
```
## Dynamic paths
You can use named arguments to allow dynamic paths. Use `$args->request->routeParam()` to access named arguments. Endpoint paths support all features provided by [ProcessWire URL path hooks](https://processwire.com/blog/posts/pw-3.0.173/#introducing-url-path-hooks), including optional segments and regex constraints.
```php{3}
$this->addEndpoint('/animals/{animal}')->get(function ($args) {
return new Response([
'animal_name' => $args->request->routeParam('animal'),
]);
});
```
Querying `/animals/bunny-rabbit` results in a following JSON:
```json
{
"data": {
"animal_name": "bunny-rabbit"
}
}
```
---
# Requests
Request object can be accessed in callback functions of:
- [Endpoint request handler](#endpoint-handler-arguments)
- [Request hook handler](#hook-arguments)
- [Error hook handler](#error-hook-arguments)
- [API instance exception handler](#exception-handling)
## Request properties
::: warning
Properties like `body`, `queryParams`, `routeParams`, `headers`, `cookies`, and `files` contain **raw, unsanitized input from the client**. Always sanitize values before using them — for example with ProcessWire's [$sanitizer API](https://processwire.com/api/ref/sanitizer/).
:::
| Property | Type | Description |
| ------------- | --------------------- | ------------------------------------------------------------------ |
| `method` | `string` | Request method |
| `methodEnum` | `RequestMethod\|null` | Method as enum |
| `path` | `string\|null` | Requested path |
| `routeParams` | `array` | Route parameters of [dynamic paths](#dynamic-paths) |
| `queryParams` | `array` | Query parameters |
| `headers` | `array` | Request headers |
| `contentType` | `string\|null` | `Content-Type` header |
| `accept` | `string\|null` | `Accept` header |
| `cookies` | `array` | Shorthand for `$_COOKIE` |
| `ip` | `string\|null` | Shorthand for `$_SERVER['REMOTE_ADDR']` |
| `userAgent` | `string\|null` | Shorthand for `$_SERVER['HTTP_USER_AGENT']` |
| `protocol` | `string\|null` | Shorthand for `$_SERVER['SERVER_PROTOCOL']` |
| `body` | `mixed` | Request body |
| `files` | `array` | Normalized value of `$_FILES` |
### Body
If the request `Content-Type` header is `application/json`, the request body will be parsed from `php://input`. In such cases, the body must be a valid JSON string. If the JSON is malformed, an `ApiException` will be thrown.
For all other cases, the body will contain the raw value of `$_POST` superglobal.
### Files
By default, `$_FILES` superglobal has a non-intuitive structure when it contains multiple files for the same field. `files` property contains a normalized value. The structure is always an array, regardless of the number of files.
#### Single file
```json
{
"my_file_field": [
{
"name": "foo.txt",
"full_path": "foo.txt",
"type": "text/plain",
"tmp_name": "/tmp/phprawUMP",
"error": 0,
"size": 4
}
]
}
```
#### Multiple files
```json
{
"my_file_field": [
{
"name": "foo.txt",
"full_path": "foo.txt",
"type": "text/plain",
"tmp_name": "/tmp/phprawUMP",
"error": 0,
"size": 4
},
{
"name": "bar.txt",
"full_path": "bar.txt",
"type": "text/plain",
"tmp_name": "/tmp/php8P7qcq",
"error": 0,
"size": 4
}
]
}
```
## Request methods
### routeParam()
Get route parameter. If parameter is not found, `null` is returned.
```php
$request->routeParam('product');
```
### queryParam()
Get query parameter. If parameter is not found, `null` is returned.
```php
$request->queryParam('product');
```
### header()
Get a header value by name. The lookup is case-insensitive per [RFC 7230](https://www.rfc-editor.org/rfc/rfc7230#section-3.2). Returns `null` if not found.
```php
$request->header('Content-Type');
$request->header('x-custom-header');
```
### toArray()
Return all request properties as an associative array.
```php
$request->toArray();
```
---
# Responses
The data you return with the response will be rendered under the `data` key in the resulting JSON.
## Example response
```php
return new Response([
'first_name' => 'Jerry',
'last_name' => 'Cotton',
]);
```
The resulting JSON:
```json
{
"data": {
"first_name": "Jerry",
"last_name": "Cotton"
}
}
```
## Response code
The default response code is `200`. Use `code()` to specify a custom response code.
```php
return (new Response([
'first_name' => 'Jerry',
'last_name' => 'Cotton',
]))->code(201);
```
## Additional top-level keys
Sometimes you need to return additional key-value pairs, such as a message, alongside the data. In this example, the response data contains user details, and the message would not belong there. Use `with()` to define additional keys.
```php{4-6}
return (new Response([
'first_name' => 'Jerry',
'last_name' => 'Cotton',
]))->with([
'message' => 'Your details were saved successfully!',
]);
```
The resulting JSON:
```json
{
"data": {
"first_name": "Jerry",
"last_name": "Cotton"
},
"message": "Your details were saved successfully!"
}
```
## Response without data
If you pass `null` to the constructor, the `data` key will be omitted from the response. This is useful when the response only contains additional keys.
```php
return (new Response(null))->with([
'message' => 'Action completed successfully!',
]);
```
The resulting JSON:
```json
{
"message": "Action completed successfully!"
}
```
## HTTP headers
Use `header()` to set custom HTTP headers on the response. Headers set this way are emitted alongside `Content-Type` when the response is sent.
```php
return (new Response([
'first_name' => 'Jerry',
'last_name' => 'Cotton',
]))->header('X-Custom-Header', 'custom-value');
```
Multiple headers can be chained:
```php
return (new Response())->header('X-First', 'one')->header('X-Second', 'two');
```
## Paginated responses
`PaginatedResponse` extends `Response` and adds a `pagination` key to the output. It provides `start()`, `limit()`, and `total()` setters, plus computed `page` and `pages` values.
::: warning
This class does **not** perform pagination. It only attaches pagination metadata to the response. Actual pagination must be handled elsewhere, for example with ProcessWire selectors using `start` and `limit`.
:::
```php
use PwJsonApi\PaginatedResponse;
return (new PaginatedResponse(['foo', 'bar', 'baz']))
->start(10)
->limit(10)
->total(85);
```
The resulting JSON:
```json
{
"data": ["foo", "bar", "baz"],
"pagination": {
"start": 10,
"limit": 10,
"total": 85,
"page": 2,
"pages": 9
}
}
```
All three setters — `start()`, `limit()`, and `total()` — must be called before `toArray()` or `toJson()`. A `LogicException` is thrown if any value is missing.
The computed values are derived as follows:
- **`page`** — current page number (1-based): `floor(start / limit) + 1`
- **`pages`** — total number of pages: `ceil(total / limit)`
## Methods
### toArray()
Return the response as an associative array.
```php
$response->toArray();
```
---
# Error handling
The library catches any exception derived from the `ApiException` class and converts it to a response. You can throw an `ApiException` at any time to halt the process.
::: tip
To handle other exceptions, such as `WireException`, you need to specify custom handling function by calling `handleException()` method of the API instance. [Read more about exception handling](#exception-handling).
:::
## Example exception
```php
throw new ApiException('Snap, crackle and pop!');
```
The resulting JSON:
```json
{
"error": "Snap, crackle and pop!"
}
```
## Exception response code
The default response code is `400`. Use `code()` to specify a custom code.
```php
throw (new ApiException('Snap, crackle and pop!'))->code(401);
```
## Additional top-level keys
Like responses, exceptions can also contain additional key-value pairs.
```php
throw (new ApiException())->code(401)->with([
'login_url' => 'https://example.com/login',
]);
```
The resulting JSON:
```json
{
"login_url": "https://example.com/login"
}
```
## HTTP headers
Use `header()` to set custom HTTP headers on the error response.
```php
throw (new ApiException('Forbidden'))
->code(403)
->header('X-Reason', 'insufficient-permissions');
```
## Api404Exception
This can be used as a shorthand for an exception with a `404` response code.
```php
throw new Api404Exception();
```
---
# Authentication & authorization
Authentication verifies _who_ the user is. Authorization verifies _what_ the user is allowed to do. These are two independent mechanisms — you can use either or both.
::: tip Looking for a ready-made solution?
See [ProcessWire authentication](#processwire-authentication) for a built-in authenticator and login/logout service that uses ProcessWire's session system.
:::
## How levels work
Both mechanisms can be configured on three levels: API, service, or endpoint. A level applies to all of its children, but the two mechanisms combine levels differently:
| | Authentication | Authorization |
| ------------------------ | --------------------------------------- | ----------------------------------- |
| Configured with | `authenticate()` | `authorize()` |
| When multiple levels | Closest wins (endpoint > service > API) | All run (API → services → endpoint) |
| Child level can override | Yes | No |
| Failure | `AuthenticationException` (401) | `AuthorizationException` (403) |
## Authentication
### Creating an authenticator
Authenticators extend the abstract `Authenticator` class. It provides access to the ProcessWire API via `$this->wire` (like `ApiPlugin`). The `authenticate()` method receives an [`AuthenticateArgs`](#argument-objects) object and should throw `AuthenticationException` on failure.
```php
use PwJsonApi\{AuthenticateArgs, AuthenticationException, Authenticator};
class ExampleAuth extends Authenticator
{
public function authenticate(AuthenticateArgs $args): void
{
if ($this->wire->user->isLoggedin() === false) {
throw new AuthenticationException();
}
}
}
```
### Setting an authenticator
Pass an authenticator instance to the `authenticate()` method of the API, a service, or an endpoint:
```php
use PwJsonApi\Api;
$api = new Api();
$api->authenticate(new ExampleAuth());
```
All services and endpoints under the level inherit the authenticator.
### Authenticator is not chained
If multiple levels define an authenticator, the **closest to the endpoint wins** (endpoint > service > API). Only that authenticator runs — the authenticators on the levels above it are skipped. This allows you to use a different authentication method for a part of the API, or to [make it public](#opting-out).
## Authorization
Authorization is configured by passing a callback to the `authorize()` method. The callback receives an [`AuthorizeArgs`](#argument-objects) object and returns `bool` — `false` results in a `403` response.
```php
use PwJsonApi\AuthorizeArgs;
$service->authorize(function (AuthorizeArgs $args) {
return $args->user->hasRole('editor');
});
```
### Authorization is chained
Unlike authentication, **all authorization callbacks in the chain are executed** in order: API → services → endpoint. If any callback returns `false`, the request is rejected. A child level cannot override the callbacks of the levels above it.
```php
// API: must be logged in
$api->authorize(function (AuthorizeArgs $args) {
return $args->user->isLoggedin();
});
// Service: must have editor role
$api->addService(new ContentService(), function ($service) {
$service->authorize(function (AuthorizeArgs $args) {
return $args->user->hasRole('editor');
});
});
// Both callbacks run: first API, then service
```
## Argument objects
Authenticators receive an `AuthenticateArgs` object and authorization callbacks receive an `AuthorizeArgs` object. Both contain the same properties:
| Property | Type | Description |
| ----------- | ----------------------- | ------------------------------------------------------------ |
| `$request` | `Request` | The current request |
| `$user` | `ProcessWire\User` | The current ProcessWire user |
| `$event` | `ProcessWire\HookEvent` | ProcessWire URL hook event |
| `$endpoint` | `Endpoint` | Requested endpoint |
| `$service` | `Service` | Requested service |
| `$services` | `ServiceList` | List of all parent services |
| `$api` | `Api` | API instance |
## Public and protected endpoints
There are three ways to combine public and protected endpoints in the same API. Start from the first one, and move on only if it does not fit your structure.
### Protect specific services
The simplest approach is to set the authenticator only on the services that need it, instead of the API:
```php
// Only MyProtectedService requires authentication
$api->addService(new MyPublicService());
$api->addService(new MyProtectedService(), function ($service) {
$service->authenticate(new ExampleAuth());
});
```
### Opting out
If most of the API is protected, set the authenticator on the API and opt specific services or endpoints out with the built-in `PublicAuth` authenticator. It accepts every request, and because the closest authenticator wins, it replaces the inherited one:
```php
use PwJsonApi\Auth\PublicAuth;
```
```php
// In service init(): this endpoint is public despite the API authenticator
$this->addEndpoint('/status')
->get(function () {
return new Response(['status' => 'ok']);
})
->authenticate(new PublicAuth());
```
`PublicAuth` can also be extended, for example to log or track requests to public endpoints.
::: warning
Opting out only affects authentication. Authorization callbacks are [chained](#authorization-is-chained) and always run, so a callback set on a parent level still applies to the opted-out endpoint. Use [conditional rules](#conditional-rules) to exempt it.
:::
### Conditional rules
Authenticators and authorization callbacks receive the requested endpoint and service in their [argument objects](#argument-objects), so a single parent level rule can let specific requests through. This is the only way to exempt requests from a parent level authorization callback:
```php
// API: must be logged in, except for PublicContentService
$api->authorize(function (AuthorizeArgs $args) {
if ($args->service instanceof PublicContentService) {
return true;
}
return $args->user->isLoggedin();
});
```
`$service` is the service the endpoint belongs to. For nested services, use `$services` to inspect the parent services.
Prefer `instanceof` checks over comparing paths or names as strings. A string comparison silently stops matching when a path or name changes.
::: tip When to use which
- **Protect specific services** when public and protected endpoints are already in separate services.
- **Opting out** when a single service or endpoint should be public. The exception is visible where the service or endpoint is defined.
- **Conditional rules** when a rule spans many services or endpoints, or when you need to bypass a parent level authorization callback.
:::
## Execution order
Authentication and authorization run **before** request hooks and plugins:
1. **Authenticate** — closest authenticator runs
2. **Authorize** — all authorization callbacks in the chain run (API → services → endpoint)
3. Before hooks, including hooks registered by [plugins](#plugins)
4. Endpoint handler
5. After hooks
See [Application lifecycle](#request-handling) for the full sequence.
## Exceptions
Both `AuthenticationException` (401) and `AuthorizationException` (403) extend `ApiException`, so they can be caught in [error hooks](#error-hooks):
```php
use PwJsonApi\{AuthenticationException, AuthorizationException};
$api->hookOnError(function ($args) {
if ($args->exception instanceof AuthenticationException) {
$args->response->with(['login_url' => '/login']);
}
});
```
---
# ProcessWire authentication
A built-in authenticator that uses ProcessWire's session-based authentication. It checks whether the current user is logged in via `$user->isLoggedin()`.
## Setup
`ProcessWireAuth` is the authenticator class. `ProcessWireAuthService` provides login and logout endpoints.
When using session-based authentication with a browser frontend, it is recommended to also install the [CSRF plugin](#csrf-protection) on the API instance to protect against cross-site request forgery.
::: tip Authentication level
Set the authenticator on individual services, not on the API instance. The login and logout endpoints must remain publicly accessible — they cannot be behind authentication.
:::
```php
use PwJsonApi\Api;
use PwJsonApi\Auth\{ProcessWireAuth, ProcessWireAuthService};
use PwJsonApi\Plugins\CSRFPlugin;
$api = new Api();
$api->addPlugin(new CSRFPlugin());
// Login and logout endpoints (public)
$api->addService(new ProcessWireAuthService());
// This protected service requires authentication
$api->addService(new MyProtectedService());
$api->run();
```
`MyProtectedService` configures authentication in its `init()` method:
```php
use PwJsonApi\Auth\ProcessWireAuth;
use PwJsonApi\{Response, Service};
class MyProtectedService extends Service
{
protected function init()
{
// Require authentication for this service
$this->authenticate(new ProcessWireAuth());
// Optional: require specific role
// $this->authorize(function ($args) {
// return $args->user->hasRole('editor');
// });
$this->addEndpoint('/me')->get(function ($args) {
return new Response([
'name' => $args->user->name,
]);
});
// Child services inherit authentication
$this->addService(new ProductService());
$this->addService(new OrderService());
}
}
```
## Endpoints
`ProcessWireAuthService` registers endpoints under the `/auth` base path.
### POST /auth/login
Authenticates a user with username and password.
**Request body:**
```json
{
"username": "my-username",
"password": "my-password"
}
```
**Responses:**
| Status | Description |
| ------ | ----------------------------------- |
| 200 | Login successful |
| 401 | Invalid credentials |
| 429 | Too many attempts (login throttled) |
When the `SessionLoginThrottle` module is installed (default in ProcessWire), repeated failed login attempts will result in `429` responses. See [Login throttling](#login-throttling).
### POST /auth/logout
Ends the current session.
**Responses:**
| Status | Description |
| ------ | ----------------- |
| 200 | Logout successful |
The logout endpoint does not require authentication — calling it without an active session is a safe no-op.
## CSRF protection
When using session-based authentication with a browser frontend, it is recommended to also enable the [CSRF plugin](#csrf-protection) to protect against cross-site request forgery.
```php
use PwJsonApi\Plugins\CSRFPlugin;
$api->addPlugin(new CSRFPlugin());
```
In ProcessWire, every user has a session (including guests), so the CSRF token is always available. When the CSRF plugin is installed, it automatically protects all POST endpoints — including login and logout.
## Login throttling
ProcessWire's `SessionLoginThrottle` module (installed by default) is automatically active for all login requests made through `ProcessWireAuthService`, regardless of content type. It throttles repeated failed login attempts by imposing an increasing delay for each attempt.
## Authorization
`ProcessWireAuth` only handles authentication (verifying identity). To restrict access based on user roles or permissions, add [authorization](#authorization) callbacks to your services or endpoints:
```php
use PwJsonApi\AuthorizeArgs;
$api->addService(new AdminService(), function ($service) {
$service->authenticate(new ProcessWireAuth());
$service->authorize(function (AuthorizeArgs $args) {
return $args->user->isSuperuser();
});
});
```
## Customizing error responses
Use [error hooks](#error-hooks) to add data to authentication or authorization error responses:
```php
use PwJsonApi\{AuthenticationException, AuthorizationException};
$api->hookOnError(function ($args) {
if ($args->exception instanceof AuthenticationException) {
$args->response->with([
'message' => 'Please log in to access this resource.',
]);
}
if ($args->exception instanceof AuthorizationException) {
$args->response->with([
'message' => 'You do not have permission to access this resource.',
]);
}
});
```
---
# Request hooks
Request hooks can be used to modify the behavior of endpoints. The most common use cases are to validate or modify requests before they are handled, or to modify response data after the request has been handled. For these purposes, [hook arguments](#hook-arguments) will be passed to the hook handler functions.
The examples below use the `hookBefore()` and `hookAfter()` methods, which apply to any request method. There are also [request type-specific hooks](#hook-methods-reference) available.
## Hook scopes
### API hooks
Defined for the whole API instance. These hooks will apply to all endpoints.
::: tip
For authentication and authorization, consider using the dedicated [`authenticate()`](#authentication-authorization) and [`authorize()`](#authorization) methods instead of hooks.
:::
```php
// Require JSON content type for POST requests
$api->hookBeforePost(function ($args) {
$contentType = $args->event->request->getHeader('Content-Type');
if (str_contains($contentType, 'application/json') === false) {
throw (new ApiException('Content-Type must be application/json'))->code(
415,
);
}
});
// Modify response data of every successful response
$api->hookAfter(function ($args) {
// Inject key to response data
$args->response->data['_foo'] = 'foo';
// Include additional top-level keys in the response
$args->response->with([
'bar' => 'bar',
]);
});
```
### Service hooks
Defined for a single service branch. These hooks will apply to all endpoints within the given service (including child services). Service hooks can be defined directly in the service `init()` method or injected into the service object.
#### Define in init()
```php
$this->hookAfter(function ($args) {
$args->response->data['_foo'] = 'foo';
});
```
#### Inject in addService() callback
```php
$api->addService(new HelloWorldService(), function ($service) {
$service->hookAfter(function ($args) {
$args->response->data['_foo'] = 'foo';
});
});
```
#### Find installed service and inject
```php
$api->findService('HelloWorldService')?->hookAfter(function ($args) {
$args->response->data['_foo'] = 'foo';
});
```
### Endpoint hooks
Defined for a single endpoint. Endpoint hooks can be defined directly when creating an endpoint, or they can be injected into the endpoint object.
#### Define directly in endpoint
```php
$this->addEndpoint('/hello-world')
->get(function () {
return new Response([
'hello' => 'world',
]);
})
->hookAfter(function ($args) {
$args->response->data['_foo'] = 'foo';
});
```
#### Find existing endpoint and inject
```php
$api->findEndpoint('/api/hello-world')?->hookAfter(function ($args) {
$args->response->data['_foo'] = 'foo';
});
```
## Multiple hooks
Multiple hooks can affect the single target. [See hook execution order](#hook-execution-order).
```php
$api->hookAfter(function ($args) {
$args->response->data['_foo'] = 'foo';
});
$api->findService('HelloWorldService')?->hookAfter(function ($args) {
$args->response->data['_bar'] = 'bar';
});
$api->findEndpoint('/api/hello-world')?->hookAfter(function ($args) {
$args->response->data['_baz'] = 'baz';
});
$api->findEndpoint('/api/hello-world')?->hookAfter(function ($args) {
$args->response->data['_qux'] = 'qux';
});
```
## Hook arguments
You can access the following properties via the `$args` parameter of the handler function. The following properties are always included:
| Property | Type | Description |
| ---------- | ------------------------ | ---------------------------- |
| `request` | `Request` | [Request object](#requests) |
| `user` | `\ProcessWire\User` | The current ProcessWire user |
| `event` | `\ProcessWire\HookEvent` | ProcessWire URL hook event |
| `endpoint` | `Endpoint` | Requested endpoint |
| `service` | `Service` | Requested service |
| `services` | `ServiceList` | List of all parent services |
| `api` | `Api` | API instance |
### hookBefore\* arguments
| Property | Type | Description |
| --------- | ---------- | ------------------------ |
| `handler` | `callable` | Endpoint request handler |
### hookAfter\* arguments
| Property | Type | Description |
| ---------- | -------- | -------------------------------------- |
| `response` | Response | Response from endpoint request handler |
## Hook methods reference
### Before request
| Method | Description |
| -------------------- | -------------------------- |
| `hookBefore()` | Hook before any request |
| `hookBeforeGet()` | Hook before GET request |
| `hookBeforePost()` | Hook before POST request |
| `hookBeforeHead()` | Hook before HEAD request |
| `hookBeforePut()` | Hook before PUT request |
| `hookBeforePatch()` | Hook before PATCH request |
| `hookBeforeDelete()` | Hook before DELETE request |
### After request
| Method | Description |
| ------------------- | ------------------------- |
| `hookAfter()` | Hook after any request |
| `hookAfterGet()` | Hook after GET request |
| `hookAfterPost()` | Hook after POST request |
| `hookAfterHead()` | Hook after HEAD request |
| `hookAfterPut()` | Hook after PUT request |
| `hookAfterPatch()` | Hook after PATCH request |
| `hookAfterDelete()` | Hook after DELETE request |
## Hook execution order
1. API before hooks
2. Service before hooks
3. Endpoint before hooks
4. **Request handler**
5. Endpoint after hooks
6. Service after hooks
7. API after hooks
---
# Error hooks
Like request hooks, error hooks can be used to modify the response when an `ApiException` is thrown. The behavior of error hooks is similar, except that there are no 'before' or 'after' timings.
An `ErrorHookReturn` object will be passed to the hook handler function. [The hook execution order](#hook-execution-order) remains the same.
## Error hook scopes
### API error hooks
Defined for the whole API instance. These hooks will apply to all errors.
```php
// For 401 errors, provide login URL
$api->hookOnError(function ($args) {
if ($args->response->code === 401) {
$args->response->with([
'login_url' => 'https://example.com/login',
]);
}
});
```
### Service error hooks
Defined for a single service branch. These hooks will apply to all errors thrown in endpoints within the given service (including child services). Service hooks can be defined directly in the service `init()` method or injected into the service object.
#### Define in init()
```php
$this->hookOnError(function ($args) {
if ($args->response->code === 401) {
$args->response->with([
'login_url' => 'https://example.com/login',
]);
}
});
```
#### Inject in addService() callback
```php
$api->addService(new HelloWorldService(), function ($service) {
$service->hookOnError(function ($args) {
if ($args->response->code === 401) {
$args->response->with([
'login_url' => 'https://example.com/login',
]);
}
});
});
```
#### Find installed service and inject
```php
$api->findService('HelloWorldService')?->hookOnError(function ($args) {
if ($args->response->code === 401) {
$args->response->with([
'login_url' => 'https://example.com/login',
]);
}
});
```
### Endpoint error hooks
Defined for a single endpoint. Endpoint hooks can be defined directly when creating an endpoint, or they can be injected into the endpoint object.
#### Define directly in endpoint
```php
$this->addEndpoint('/hello-world')
->get(function () {
return new Response([
'hello' => 'world',
]);
})
->hookOnError(function ($args) {
$args->response->with(['_foo' => 'foo']);
});
```
#### Find existing endpoint and inject
```php
$api->findEndpoint('/api/hello-world')?->hookOnError(function ($args) {
$args->response->with(['_foo' => 'foo']);
});
```
## Error hook arguments
You can access the following properties via the `$args` parameter of the handler function.
| Property | Type | Description |
| ----------- | ------------------------ | ---------------------------- |
| `exception` | `ApiException` | The thrown exception |
| `response` | `Response` | Error Response object |
| `request` | `Request` | [Request object](#requests) |
| `user` | `\ProcessWire\User` | The current ProcessWire user |
| `event` | `\ProcessWire\HookEvent` | ProcessWire URL hook event |
| `endpoint` | `Endpoint` | Requested endpoint |
| `service` | `Service` | Requested service |
| `services` | `ServiceList` | List of all parent services |
| `api` | `Api` | API instance |
---
# Plugins
Plugins are reusable packages of hooks, services, and configuration that can be installed on an API instance, a service, or an endpoint. They provide a way to encapsulate cross-cutting concerns and share them across projects.
## The `ApiPlugin` class
All plugins extend the abstract `ApiPlugin` class. Override the `init()` method to add hooks, services, or any other setup logic to the context where the plugin is installed.
```php
use PwJsonApi\Plugins\ApiPlugin;
use PwJsonApi\{Api, Service, Endpoint};
use ProcessWire\WireException;
class MyPlugin extends ApiPlugin
{
public function init(Api|Service|Endpoint $context): static
{
// Optional: restrict plugin to API-level only
// if (!($context instanceof Api)) {
// throw new WireException('MyPlugin must be installed on Api');
// }
parent::init($context);
$context->hookAfter(function ($args) {
$args->response->with([
'plugin_active' => true,
]);
});
return $this;
}
}
```
## Installing a plugin
Use the `addPlugin()` method available on `Api`, `Service`, and `Endpoint` instances.
```php
$api->addPlugin(new MyPlugin());
```
An optional setup callback gives you access to the plugin instance for configuration before it is initialized:
```php
$api->addPlugin(new MyPlugin(), function ($plugin) {
$plugin->someSetting = 'value';
});
```
## Scopes
Plugins can be installed at three different levels:
### API-level
API-level plugins affect the entire API.
```php
$api->addPlugin(new MyPlugin());
```
### Service-level
Service-level plugins affect only the endpoints within that service and its child services.
```php
$api->addService(new MyService(), function ($service) {
$service->addPlugin(new MyPlugin());
});
```
### Endpoint-level
Endpoint-level plugins affect a single endpoint.
```php
$service->findEndpoint('/')?->addPlugin(new MyPlugin());
```
---
# CSRF protection
This plugin adds [cross-site request forgery (CSRF)](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF) protection for your endpoints, utilising ProcessWire’s built-in token validation.
The plugin hooks into every `POST`, `PUT`, `PATCH`, or `DELETE` request and expects a valid token to be included in the request payload. Any successful request of those types triggers token rotation, and the new token is included in the response. If validation fails, an `ApiException` is thrown with the current token included.
The plugin also exposes a `/csrf-token` endpoint, which can be used to retrieve the current token.
## Installation
Install the plugin by calling the `addPlugin()` method of the `Api` instance.
```php
use PwJsonApi\Plugins\CSRFPlugin;
$api->addPlugin(new CSRFPlugin());
```
## Configuration
You can configure the plugin via the setup callback:
```php
$api->addPlugin(new CSRFPlugin(), function ($plugin) {
// Token name
$plugin->tokenName = 'pw_json_api_csrf_token';
// Key name for the token in responses
$plugin->tokenKey = 'csrf_token';
// Endpoint path for retrieving the current token
$plugin->endpointPath = '/csrf-token';
});
```
## Front-end implementation
Front-end implementation is outside the scope of this guide, but here is a basic flow:
1. Implement a way to store the current token (e.g. in memory, or a state manager).
2. Before any `POST`, `PUT`, `PATCH`, or `DELETE` request, check whether a current token is available. If not, query the `/csrf-token` endpoint to retrieve and store it.
3. Pass the token as a header
4. If the response is successful, read the new token from the response data using the `csrf_token` key and update your store.
```js
let token = null;
// Fetch the initial token
async function fetchToken() {
const res = await fetch('/api/csrf-token');
const json = await res.json();
if (json.data.csrf_token) {
token = json.data.csrf_token;
}
}
// Make a POST request with the token, then store the rotated token
async function post(url, body) {
if (!token) {
await fetchToken();
}
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[`X-${token.name}`]: token.value,
},
body: JSON.stringify(body),
});
const json = await res.json();
// Store the rotated token
if (json.data.csrf_token) {
token = json.data.csrf_token;
}
return json;
}
const result = await post('/api/my-endpoint', { foo: 'bar' });
```
---
# Rate limiting
This plugin adds rate limiting for your API endpoints using a fixed-window algorithm. It uses ProcessWire's WireCache for storage, which means request counts are persisted in the database and survive across requests.
This plugin can only be installed on an `Api` instance.
## Installation
Install the plugin by calling the `addPlugin()` method on your `Api` instance.
```php
use PwJsonApi\Plugins\RateLimitPlugin;
$api->addPlugin(new RateLimitPlugin());
```
## Configuration
You can configure the plugin via the setup callback:
```php
$api->addPlugin(new RateLimitPlugin(), function ($plugin) {
// Maximum requests per window (default: 60)
$plugin->limit = 100;
// Window duration in seconds (default: 60)
$plugin->window = 120;
// Error message when limit is exceeded (default: 'Rate limit exceeded')
$plugin->errorMessage = 'Too many requests, please try again later';
});
```
## Response headers
The plugin sets the following headers on every response:
| Header | Description |
| ----------------------- | -------------------------------------------------- |
| `X-RateLimit-Limit` | Maximum number of requests allowed per window |
| `X-RateLimit-Remaining` | Number of requests remaining in the current window |
| `X-RateLimit-Reset` | Unix timestamp when the current window resets |
When the rate limit is exceeded, additional headers are included:
| Header | Description |
| ------------- | ------------------------------------------------- |
| `Retry-After` | Number of seconds until the current window resets |
The response body also includes `retry_after` with the same value.
## Custom client identifier
By default, the plugin identifies clients by their IP address (`$request->ip`). If no IP address is available (e.g. in CLI contexts), rate limiting is skipped. You can provide a custom identifier using the `$clientId` callback — returning `null` will skip rate limiting for that request:
```php
use function ProcessWire\wire;
$api->addPlugin(new RateLimitPlugin(), function ($plugin) {
$plugin->clientId = function ($request) {
// Rate limit by user ID for authenticated users, IP for guests
$user = wire()->user;
return $user->isGuest() ? $request->ip : (string) $user->id;
};
});
```
## Algorithm
The plugin uses a **fixed-window** algorithm:
1. Time is divided into fixed windows (e.g. 60-second intervals).
2. Each request increments a counter stored in WireCache.
3. If the counter exceeds the limit, a `429` response is returned.
4. When the window expires, the counter resets automatically via WireCache TTL.
---
# ProcessWire Page Parser
The **Page Parser** is a flexible tool for converting ProcessWire pages into structured data, ready for API responses. It supports all ProcessWire core field types and offers extensive configuration and hook options.
- Parse single pages or arrays of pages
- Select specific fields to include or exclude
- Optionally include child pages, with depth and selector controls
- Use hooks to customize parsing at various stages (pages, fields, images, files)
## Basic usage example
::: tip
Note that `toArray()` or `toResponse()` starts the actual parsing. Any configuration must be done before calling one of these methods.
:::
```php
use PwJsonApi\PageParser;
$output = (new PageParser())
->input($this->wire->pages->find('template=basic-page'))
->fields('title', 'body')
->toArray();
```
## Configuration
Configure the parser using the `configure()` method, which allows you to control recursion, child page selection, output format, and more.
```php
$parser->configure(function ($config) {
// Recursively parse child pages? (default: false)
$config->parseChildren = false;
// Selector for child pages (default: '')
$config->childrenSelector = '';
// Key name for child pages (default: '_children')
$config->childrenKey = '_children';
// Recursively parse children of page field references? (default: false)
$config->parsePageReferenceChildren = false;
// Maximum depth for recursive parsing (default: 3)
$config->maxDepth = 3;
// Output full file URLs (default: true)
$config->fullFileUrls = true;
// Parse custom fields of files? (default: false)
$config->parseFileCustomFields = false;
// Key name for custom fields of files (default: '_custom_fields')
$config->fileCustomFieldsKey = '_custom_fields';
});
```
## Input
Specify the pages to parse with `input()`. Accepts a `Page` or `PageArray`.
```php
$parser->input($this->wire->pages->find('template=basic-page'));
```
## Property selection
By default, `id` and `name` will be included.
### Include properties
Use `properties()` to include additional `Page` properties beyond the defaults.
```php
$parser->properties('url', 'numChildren');
```
### Exclude properties
Use `excludeProperties()` to omit certain properties.
```php
$parser->excludeProperties('name');
```
## Field Selection
### Include fields
By default, all fields of the input pages will be included. Use `fields()` to include only specific fields.
::: tip
If your input contains pages with different templates, you can still specify all fields here. If the parsed template does not have the given field, it will be ignored.
:::
```php
$parser->fields('title', 'body');
```
### Exclude fields
Use `excludeFields()` to omit certain fields.
```php
$parser->excludeFields('body');
```
## Child pages
Enable child page parsing via [parser configuration](#configuration). Fine-tune with selectors, depth, and [`hooks`](#hookbeforepageparse).
## Output
### As an array
`toArray()` returns the parsed data as an array.
```php{4}
$result = (new PageParser())
->input($this->wire->pages->find('template=basic-page'))
->fields('title', 'body')
->toArray();
```
### As a response
`toResponse()` returns a Response object.
```php{4}
$response = (new PageParser())
->input($this->wire->pages->find('template=basic-page'))
->fields('title', 'body')
->toResponse();
```
### As a paginated response
`toPaginatedResponse()` returns a [PaginatedResponse](#paginated-responses) with pagination metadata read from the `PageArray` input. The input must be a `PageArray` — an `InvalidArgumentException` is thrown otherwise.
```php{4}
$response = (new PageParser())
->input($this->wire->pages->find('template=basic-page, start=0, limit=10'))
->fields('title', 'body')
->toPaginatedResponse();
```
## Hooks
Hooks allow you to modify data before and after parsing pages, fields, images, and files. This enables advanced customization, such as resizing images, altering field values, or adding extra data.
::: tip
Hooks must be defined before the `toArray()` or `toResponse()` call.
:::
::: tip
All hook callbacks receive an `$args->depth` property (int) indicating the
current recursion depth (starting at 1). Use this to customize behavior at
different nesting levels.
:::
### Skip
The `skip()` method is available in `hookBeforePageParse()`, `hookBeforePropertyParse()`, and `hookBeforeFieldParse()` callbacks. When called, the item is omitted from the output entirely — the key will not appear (not even as `null`). This also provides a performance benefit, as recursive fields are not parsed when skipped.
```php
// Skip unpublished pages
$parser->hookBeforePageParse(function ($args) {
if ($args->page->isUnpublished()) {
$args->skip();
}
});
// Skip the body field at depth > 1
$parser->hookBeforeFieldParse(function ($args) {
if ($args->field->name === 'body' && $args->depth > 1) {
$args->skip();
}
});
// Skip the id property
$parser->hookBeforePropertyParse(function ($args) {
if ($args->propertyName === 'id') {
$args->skip();
}
});
```
### hookBeforePageParse()
Use this to modify the source page.
```php
$parser->hookBeforePageParse(function ($args) {
// Get only the first three tags
$args->page->tags = $args->page->tags->slice(0, 3);
// Do some other nasty modifications to the source page
$args->page->title = 'Remember that it was your idea...';
// Reconfigure parser for child pages
$args->parser->fields('title');
});
```
### hookAfterPageParse()
Use this to modify the final parsed data of the page, add extra keys etc.
```php
$parser->hookAfterPageParse(function ($args) {
$args->parsedPage['_foo'] = 'foo';
if ($args->page->template->name === 'bar') {
$args->parsedPage['_bar'] = 'bar';
}
});
```
### hookBeforePropertyParse()
Use this to modify property value before it's parsed.
::: tip
This hook applies only to page properties, such as `id`. Use the [hookBeforeFieldParse()](#hookbeforefieldparse) hook for fields.
:::
```php
$parser->hookBeforePropertyParse(function ($args) {
if ($args->propertyName === 'id') {
$args->value = 99999;
}
});
```
### hookAfterPropertyParse()
Use this to modify final parsed output of property.
::: tip
This hook applies only to page properties, such as `id`. Use the [hookAfterFieldParse()](#hookafterfieldparse) hook for fields.
:::
```php
$parser->hookAfterPropertyParse(function ($args) {
if ($args->propertyName === 'id') {
$args->parsedValue = 99999;
}
});
```
### hookBeforeFieldParse()
Use this to access field value, source field, source page, and another `PageParser` instance, which will be used for fields with `Page` or `PageArray` as a value.
::: tip
At first glance, this hook seems like the right place to resize images. However, since the image field may contain multiple images, resizing images here would force you to re-map the entire `Pageimages` object. Instead, use the dedicated [`hookBeforeImageParse()`](#hookbeforeimageparse) hook.
:::
```php
$parser->hookBeforeFieldParse(function ($args) {
// Reconfigure parser for the field "tags" of
// "basic-page" template.
if (
$args->page->template->name === 'basic-page' &&
$args->field->name === 'tags'
) {
$args->parser->fields('title');
}
});
```
### hookAfterFieldParse()
Use this to modify final parsed output of field.
```php
$parser->hookAfterFieldParse(function ($args) {
if ($args->field->name === 'title') {
$args->parsedValue = ucfirst($args->parsedValue);
}
});
```
### hookBeforeImageParse()
Use this to modify the source image. This hook will run for every image, regardless of whether the field contains multiple images. This is a good place to resize images to avoid serving them at their original size.
`PageParser` instance for custom fields of files is exposed in `$args`.
```php
$parser->hookBeforeImageParse(function ($args) {
if ($args->field->name === 'my_image_field') {
// Resize image
$args->image = $args->image->size(300, 200);
// Reconfigure parser for custom fields
$args->parser->fields('title');
}
});
```
### hookAfterImageParse()
Use this to modify final parsed output of an image. The original (unmodified)
image is available via `$args->originalImage`, which is useful for generating
thumbnails or alternative sizes from the source image.
```php
$parser->hookAfterImageParse(function ($args) {
if ($args->field->name === 'my_image_field') {
// Even though the parser in $args is primarily used for
// custom fields of the image, you can use it to create thumbnails.
$args->parsedImage['_thumbnails'] = [
'200x200' => $args->parser->parseImage(
$args->originalImage->size(200, 200),
),
'100x100' => $args->parser->parseImage(
$args->originalImage->size(100, 100),
),
];
}
});
```
### hookBeforeFileParse()
Use this to modify the source file. This hook will run for every file, regardless of whether the field contains multiple files.
`PageParser` instance for custom fields of files is exposed in `$args`.
```php
$parser->hookBeforeFileParse(function ($args) {
if ($args->field->name === 'my_file_field') {
// Reconfigure parser for custom fields
$args->parser->fields('title');
}
});
```
### hookAfterFileParse()
Use this to modify final parsed output of a file.
```php
$parser->hookAfterFileParse(function ($args) {
if ($args->field->name === 'my_file_field') {
$args->parsedFile['_foo'] = 'bar';
}
});
```
---
# OpenAPI documentation
You can use [swagger-php](https://zircote.github.io/swagger-php/) attributes to generate an [OpenAPI](https://www.openapis.org/) spec directly from your services. The key pattern is to define endpoint handlers as named class methods instead of inline closures, giving the attributes a target to attach to.
## Install swagger-php
```console
composer require --dev zircote/swagger-php
```
## Extend the Api class
API-level metadata like the title and version belongs on an `Api` subclass. Use the `#[OA\Info]` attribute to define it:
```php
greet(...)`):
::: info
The `path` values in the attributes must include the full route as seen by the client (base path + endpoint path).
:::
```php
addEndpoint('/greet')
->get($this->greet(...))
->post($this->createGreeting(...));
}
#[
OA\Get(
path: '/documented-api/greet',
operationId: 'greet',
parameters: [
new OA\Parameter(
name: 'name',
in: 'query',
required: false,
schema: new OA\Schema(type: 'string'),
description: 'Greeting message',
),
],
responses: [
new OA\Response(response: 200, description: 'Greeting response'),
],
),
]
protected function greet(EndpointHandlerArgs $args): Response
{
return new Response([
'greeting' => $args->request->queryParam('name') ?? null,
]);
}
#[
OA\Post(
path: '/documented-api/greet',
operationId: 'createGreeting',
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
properties: [
new OA\Property(
property: 'name',
type: 'string',
description: 'Greeting message',
),
],
),
),
responses: [
new OA\Response(response: 201, description: 'Greeting created'),
],
),
]
protected function createGreeting(EndpointHandlerArgs $args): Response
{
return (new Response([
'greeting' => $args->request->body['name'] ?? null,
]))->code(201);
}
}
```
## Register the API
Wire everything up in `ready.php` the same way you would with a regular API:
```php
(new DocumentedApi())
->setBasePath('documented-api')
->addService(new DocumentedService())
->run();
```
## Generate the spec
Use the `openapi` CLI tool to scan your service files and output the spec. Assuming the API classes are placed in `site/lib`:
```console
./vendor/bin/openapi site/lib -o openapi.yaml
```
To generate JSON instead:
```console
./vendor/bin/openapi site/lib --format json -o openapi.json
```
## View and explore the spec
The generated spec file can be opened in an API client or documentation viewer:
- [Bruno](https://www.usebruno.com/) — open-source API client that can import OpenAPI specs for testing and exploration
- [Swagger UI](https://swagger.io/tools/swagger-ui/) — interactive documentation viewer that renders the spec as a browsable reference with a built-in request console