PhalconKit helps you build model-backed REST APIs without writing the same controller plumbing for every table.
You declare what the resource allows. The framework handles request parameters, query compilation, save payloads, relation loading, response formatting, permission conditions, and common REST actions.
Official Phalcon references:
- Controllers: https://docs.phalcon.io/5.16/controllers/
- Request: https://docs.phalcon.io/5.16/request/
- Response: https://docs.phalcon.io/5.16/response/
- PHQL: https://docs.phalcon.io/5.16/db-phql/
A model-backed resource can expose standard actions such as:
/api/project/find
/api/project/find-with
/api/project/find-first
/api/project/find-first-with
/api/project/save
/api/project/create
/api/project/update
/api/project/delete
/api/project/restore
Exact URLs depend on your route configuration, but the pattern is the same: controller actions map to resource operations.
Start with the app API base controller and declare the resource policy:
<?php
namespace App\Modules\Api\Controllers;
final class ProjectController extends AbstractController
{
public function initializeSaveFields(): void
{
$this->setSaveFields([
'label',
'status',
'usernode' => [
'userId',
'type',
'deleted',
],
]);
}
public function initializeFilterFields(): void
{
$this->setFilterFields([
'id',
'label',
'status',
'UserNode.userId',
'UserNode.type',
'deleted',
]);
}
public function initializeSearchFields(): void
{
$this->setSearchFields([
'id',
'label',
'status',
]);
}
public function initializeWith(): void
{
$this->setWith([
'UserNode.UserEntity',
]);
}
}This small controller tells PhalconKit:
- which fields can be written;
- which fields can be filtered;
- which fields participate in text search;
- which relations should load with
find-withandfind-first-with; - which nested relation payloads can be saved.
REST policy setters accept plain arrays and normalize them into internal
collections. Passing a Phalcon\Support\Collection is still supported when a
controller needs explicit collection behavior.
Do not use one field list for everything. A real API usually needs different rules:
save fields: client may write these.filter fields: client may query these.search fields: broad text search uses these.expose fields: response may include these.with: relations that should be eager loaded.joins: relations needed for filtering, ordering, or permission checks.permission conditions: rows the current identity may access.
This keeps public API behavior explicit and reviewable.
For legacy compatibility, a null filter-field policy keeps request filters unrestricted. Passing an empty filter-field collection is different: it keeps the policy explicit but rejects every client filter. New resources should define filter fields instead of relying on unrestricted filtering.
Filter and search field policies accept value-list entries and enabled-map
entries. Enabled maps use boolean-like normalization consistently across REST
policies: true, 1, '1', 'yes', and 'on' enable a key, while false,
0, '0', 'false', 'no', and 'off' disable it.
findAction() never eager-loads relationships. Use it for plain lists where
the response should contain only the exposed root records.
findWithAction() and findFirstWithAction() use the controller's configured
with collection when the frontend does not send a with parameter. When the
frontend does send with, PhalconKit treats the configured collection as an
allow-list and loads only the requested subset.
Configured with collections accept normal relation lists, callable relation
constraints, and enabled-map entries. Enabled-map values use the same
boolean-like normalization as field policies, so a merged config can disable a
relation with values such as false, 0, '0', or 'off'.
GET /api/projects/find-with
GET /api/projects/find-with?with=OwnerEntity,StatusEntity
GET /api/projects/find-with?with=OwnerEntity.ProfileEntity.AvatarFile
GET /api/projects/find-with?with[]=OwnerEntity&with[]=StatusEntity
GET /api/projects/find-with?with[OwnerEntity.ProfileEntity]=1
Nested paths can be requested directly. A request for
OwnerEntity.ProfileEntity.AvatarFile is passed to the eager loader as one
path, and the loader resolves the required parent relationships internally. If
the configured graph contains a deeper path such as
OwnerEntity.ProfileEntity.AvatarFile, clients may request a parent subset
such as OwnerEntity.ProfileEntity without also loading the avatar relation.
The inverse is not allowed: configuring OwnerEntity does not let a client
request OwnerEntity.ProfileEntity. Leave with null or set an empty
collection to deny all request-time relationship selection.
If the generated model has a relation alias such as UserNode, you can allow a
nested save payload:
{
"label": "Systematic Review 2026",
"status": "active",
"usernode": [
{
"userId": 10,
"type": "leader"
},
{
"userId": 11,
"type": "member"
}
]
}The controller decides which nested fields are accepted. The model layer handles relationship assignment, validation messages, and save behavior.
Filter active projects assigned to a user, search text fields, sort newest first, and limit the page:
GET /api/project/find-with?filter[status]=active&filter[UserNode.userId]=10&search=review&order[id]=desc&limit=20&offset=0Create a resource with a nested relation:
POST /api/project/create
Content-Type: application/json
{
"label": "Systematic Review 2026",
"status": "active",
"usernode": [
{
"userId": 10,
"type": "leader"
}
]
}Patch an existing resource with the same save policy:
POST /api/project/update
Content-Type: application/json
{
"id": 42,
"status": "archived"
}Those requests all flow through the controller policy: unknown save fields, unknown filters, disallowed joins, and unauthorized rows are rejected or ignored according to the app's REST configuration.
Use joins when filters or ordering depend on related tables:
public function initializeJoins(): void
{
$this->setJoins([
'UserNode' => [
\App\Models\ProjectUser::class,
'[' . $this->getModelName() . '].[id] = [UserNode].[projectId]',
'UserNode',
'left',
],
]);
}Use dynamic joins when only some filter/search paths need the join. That keeps normal list requests lighter.
Feature permissions decide whether a role can use a controller/action. Row-level conditions decide which records that role can access.
Permission action keys should be dash-case, matching route names such as
find-with or archive-project. Controller methods remain normal PHP camelCase
methods such as findWithAction() and archiveProjectAction(). PhalconKit
accepts both names during ACL checks, but new config and attributes should use
the route-style dash-case names.
public function initializePermissionConditions(): void
{
parent::initializePermissionConditions();
if (!$this->identity->hasRole($this->getSuperRoles())) {
$this->getPermissionConditions()->set(
'projectId',
$this->getProjectIdPermissionCondition('id')
);
}
}This is where you enforce project, workspace, tenant, ownership, or assignment scoping.
Exposers are fast to configure for simple CRUD. Transformers are better for external APIs or complex nested data.
use App\Modules\Api\Transformers\ProjectTransformer;
use Phalcon\Http\ResponseInterface;
public function dashboardAction(): ResponseInterface
{
$projects = $this->findWith([
'UserNode.UserEntity',
], [
'limit' => 20,
'order' => '[' . $this->getModelName() . '].[id] DESC',
]);
$this->setRestViewVar(
self::REST_VIEW_DATA,
$this->transformCollection($projects, new ProjectTransformer())
);
return $this->setRestResponse(true);
}Use transformers when response shape, field names, includes, or performance need tighter control than a simple expose list.
REST responses keep the existing envelope:
{
"timestamp": "2026-05-25T18:30:00-04:00",
"status": "OK",
"code": 200,
"response": true,
"view": {
"data": []
}
}Use the response constants and helpers when adding custom actions:
public function dashboardAction(): ResponseInterface
{
$this->setRestViewVars([
self::REST_VIEW_DATA => $this->listExpose($this->find()),
self::REST_VIEW_COUNT => $this->count(),
]);
return $this->setRestResponse(true);
}The standard envelope keys are REST_PAYLOAD_TIMESTAMP,
REST_PAYLOAD_STATUS, REST_PAYLOAD_CODE, REST_PAYLOAD_RESPONSE,
REST_PAYLOAD_VIEW, and REST_PAYLOAD_DEBUG.
The standard view keys include REST_VIEW_DATA, REST_VIEW_MESSAGES,
REST_VIEW_COUNT, REST_VIEW_FIELD, REST_VIEW_SUM, REST_VIEW_AVERAGE,
REST_VIEW_MINIMUM, REST_VIEW_MAXIMUM, REST_VIEW_SAVED,
REST_VIEW_RESULTS, REST_VIEW_STATS, REST_VIEW_DELETED,
REST_VIEW_RESTORED, and REST_VIEW_REORDERED.
The helpers do not change the payload shape. They make response contracts discoverable and keep string literals out of framework and app controllers.
Standard REST actions use error responses for failed saves, deletes, restores,
and reorders. A failed operation with validation or domain messages normally
returns 422 Unprocessable Entity. A failed operation with no messages returns
400 Bad Request, because the framework has no domain-specific reason to
expose.
PhalconKit also preserves explicit HTTP client-error codes attached to Phalcon
messages when the code is in the 400-499 range. Framework paths use this for
request-shape failures such as invalid create/update intent, missing update
targets, forbidden operations, or conflicts. Ordinary validation messages with
code 0, non-HTTP codes, server-error codes, strings, or arrays do not
override the action's default status.
Use this sparingly in application code. Message codes should represent real client-error HTTP semantics, not arbitrary business error numbers. Server-error responses should come from exceptions or explicit controller error handling.
For set logic, EXISTS often avoids duplicate rows from joins:
$key = $this->generateBindKey('assigned_user_id');
$this->getConditions()->set('assigned_user', [
'conditions' => '
EXISTS (
SELECT 1
FROM ' . \App\Models\ProjectUser::class . ' pu
WHERE pu.projectId = [' . $this->getModelName() . '].[id]
AND pu.userId = :' . $key . ':
AND pu.deleted <> 1
)
',
'bind' => [
$key => (int)$this->identity->getUserId(),
],
'bindTypes' => [
$key => \Phalcon\Db\Column::BIND_PARAM_INT,
],
]);Keep advanced filter logic in private methods or traits so the controller stays readable.
countAction() keeps the native Phalcon result in the count response field.
When a controller has a group clause, that value can be a grouped count
result instead of a scalar total.
By default, countAction() still returns only count. Clients can request
optional metadata with the same count parameter syntax used by list endpoints:
GET /resources/count?count=totalCount
GET /resources/count?count=groupedCount,bucketTotal,totalCount
GET /resources/count?count[totalCount]=1?count=1 and ?count=true are valid but only request the native count
field, which the count action already returns.
Controllers that need to always emit dashboard or facet metadata, or restrict which optional fields clients may request, can configure explicit extra fields:
public function initializeCountActionResponseFields(): void
{
$this->setCountActionResponseFields([
self::COUNT_RESPONSE_GROUPED_COUNT,
self::COUNT_RESPONSE_BUCKET_TOTAL,
self::COUNT_RESPONSE_TOTAL_COUNT,
]);
}Policy arrays and collections accept either value-list entries or enabled-map
entries.
For count field policies, enabled-map values use boolean-like normalization:
true, 1, '1', and 'yes' enable the key, while false, 0, '0',
'false', 'no', and 'off' disable it. Alias-capable policies such as
distinct fields keep string map values as aliases instead.
When this policy is null, clients may request any supported count metadata
field. When it is non-null, requested metadata is restricted to the configured
field names. Passing an empty array or collection blocks every optional
requested count metadata field while still returning the native count field.
Restful::initialize() calls this initializer after the query policy setup, so
count response metadata follows the same controller initialization pattern as
save, filter, search, joins, and other REST policies.
groupedCount: the raw grouped count result returned by the normal count query.bucketTotal: the sum of the returned grouped buckets.totalCount: a second count query with the group clause removed.
Keep bucketTotal and totalCount separate. On joined grouped endpoints, one
root record can appear in more than one bucket, so the bucket sum can differ
from the ungrouped total.
findAction() and findWithAction() can include count metadata in the normal
list response when the client requests it with the count parameter. If a
controller leaves the list-count policy as null, any supported framework count
field can be requested. Controllers only need to configure a policy when they
want to restrict or block embedded counts.
public function initializeFindActionCountFields(): void
{
$this->setFindActionCountFields([
self::REST_VIEW_COUNT,
self::COUNT_RESPONSE_BUCKET_TOTAL,
self::COUNT_RESPONSE_TOTAL_COUNT,
]);
}Request ?count=1 or ?count=true for the standard count field. Request
named fields with ?count=count,totalCount, ?count[]=count, or enabled-map
syntax such as ?count[totalCount]=1.
List counts use the prepared list query, including filters, search, joins,
permissions, identity conditions, bind values, and cache options. Limit and
offset are removed for count queries. Without a client count request, the
legacy list payload is preserved and no count query is executed. Passing an
empty array or collection to setFindActionCountFields() blocks every embedded
count field, while unsupported field names are rejected instead of becoming
dynamic response variables.
distinctAction() returns distinct scalar values for one controller-approved
field. It is useful for facets, autocomplete controls, and dashboard filters
that must obey the same filters, joins, permissions, identity scoping, bind
values, pagination, and cache policy as the normal REST query.
The action is closed by default. Configure allowed fields in the controller initializer:
public function initializeDistinctActionFields(): void
{
$this->setDistinctActionFields([
'status',
'type',
'ownerEmail' => 'Owner.email',
]);
}List entries expose and query the same field. Map entries expose a stable public
field name and query a model or joined-model alias internally. The endpoint uses
the field request parameter:
GET /api/project/distinct?field=status
GET /api/project/distinct?field=ownerEmailSuccessful responses include:
data: the returned distinct values.field: the public field requested by the client.count: the number of returned values.
Do not default this policy to all filter fields. A field can be safe to filter by without being safe or useful to enumerate publicly.
Use PhalconKit\Mvc\Controller\Rest for custom JSON endpoints such as health
checks, webhooks, dashboards, and workflows that are not plain model resources.
Use the app API base controller backed by Restful for normal CRUD/query
resources. Do not extend the model-backed controller just to return JSON.