Build Your First REST Resource
This tutorial shows the main reason to use PhalconKit: go from a database schema to a model-backed REST resource without rebuilding the same API plumbing for every table.
The example uses two tables:
project: the main API resource.project_user: users assigned to the project.
By the end, you have schema-backed models, a REST controller, nested relation writes, eager loading, permission config, and example request/response payloads.
Before starting
Complete Getting Started, configure a disposable development database, and confirm migrations run against that database. The scaffolder reads the live schema.
1. Create Or Migrate The Schema
Example MySQL schema:
CREATE TABLE project (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
label VARCHAR(120) NOT NULL,
status ENUM('draft', 'active', 'archived') NOT NULL DEFAULT 'draft',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT NULL,
deleted TINYINT(1) UNSIGNED NOT NULL DEFAULT 0,
UNIQUE KEY uniq_project_label (label)
);
CREATE TABLE project_user (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED NOT NULL,
type ENUM('leader', 'member', 'observer') NOT NULL DEFAULT 'member',
deleted TINYINT(1) UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT NULL,
UNIQUE KEY uniq_project_user (project_id, user_id, type),
KEY idx_project_user_project (project_id),
KEY idx_project_user_user (user_id)
);
Use migrations in real applications. The database is the source of truth, and the scaffolder reads that database to generate model structure.
2. Generate The Model Layer
Regenerate generated layers without overwriting concrete model files:
./vendor/bin/phalcon-kit cli scaffold run \
--src-dir=app/ \
--namespace=App \
--models-extend=\\App\\Models\\AbstractModel \
--force \
--no-models
For this schema, review the generated diff for:
ProjectAbstractandProjectUserAbstractaccessors and column maps.- uniqueness validation for
project.label; - uniqueness validation for
project_user(project_id, user_id, type); - relationship aliases inferred from
project_idanduser_id; - enum classes for
statusandtypewhen enum generation is enabled.
If a generated alias is not the one you want to expose in your API, add the app-specific relationship in the concrete model.
3. Add Business Logic To The Concrete Model
Generated abstracts mirror the schema. Concrete models hold domain behavior:
<?php
namespace App\Models;
final class Project extends Abstracts\ProjectAbstract
{
public function canBeArchived(): bool
{
return !$this->isDeleted() && $this->getStatus() === 'active';
}
public function archive(): void
{
if (!$this->canBeArchived()) {
$this->appendMessage(new \Phalcon\Messages\Message(
'Project cannot be archived from its current state',
['status'],
'InvalidStatus'
));
return;
}
$this->setStatus('archived');
}
}
Put state transitions, normalization, calculated properties, custom relationships, and extra validation in concrete models.
4. Add The REST Controller
The controller declares API policy. It tells Phalcon Kit what the client can write, filter, search, load, and access.
<?php
namespace App\Modules\Api\Controllers;
use App\Models\ProjectUser;
final class ProjectController extends AbstractController
{
public function initializeSaveFields(): void
{
$this->setSaveFields([
'label',
'status',
'usernode' => [
'userId',
'type',
'deleted',
],
]);
}
public function initializeSearchFields(): void
{
$this->setSearchFields([
'id',
'label',
'status',
]);
}
public function initializeFilterFields(): void
{
$this->setFilterFields([
'id',
'label',
'status',
'deleted',
'UserNode.userId',
'UserNode.type',
]);
}
public function initializeWith(): void
{
$this->setWith([
'UserNode.UserEntity',
]);
}
public function initializeJoins(): void
{
$this->setJoins([
'UserNode' => [
ProjectUser::class,
'[' . $this->getModelName() . '].[id] = [UserNode].[projectId]',
'UserNode',
'left',
],
]);
}
public function initializePermissionConditions(): void
{
parent::initializePermissionConditions();
if (!$this->identity->hasRole($this->getSuperRoles())) {
$this->getPermissionConditions()->set(
'projectId',
$this->getProjectIdPermissionCondition('id')
);
}
}
}
This controller allows nested project_user writes through usernode, filters by assigned users, eager-loads users for detail responses, and scopes non-super users to allowed projects.
5. Configure Role Policy
Feature permissions live in config:
'permissions' => [
'features' => [
'manageProject' => [
'components' => [
\App\Modules\Api\Controllers\ProjectController::class => ['*'],
\App\Models\Project::class => ['*'],
\App\Models\ProjectUser::class => ['*'],
],
],
'viewProject' => [
'components' => [
\App\Modules\Api\Controllers\ProjectController::class => [
'find',
'find-with',
'find-first',
'find-first-with',
],
\App\Models\Project::class => ['find'],
\App\Models\ProjectUser::class => ['find'],
],
],
],
'roles' => [
'admin' => [
'features' => ['manageProject'],
],
'researcher' => [
'features' => ['viewProject'],
],
],
],
The config says which components a role can use. The controller's permission conditions decide which rows that role can access.
The same controller actions can also be declared with attributes while keeping role assignment in config:
use PhalconKit\Mvc\Controller\Attributes\PermissionFeature;
#[PermissionFeature('manageProject', actions: '*')]
#[PermissionFeature('viewProject', actions: [
'find',
'find-with',
'find-first',
'find-first-with',
])]
final class ProjectController extends AbstractController
{
}
With that style, the config only needs to assign features to roles and keep model-level permissions:
'permissions' => [
'features' => [
'manageProject' => [
'components' => [
\App\Models\Project::class => ['*'],
\App\Models\ProjectUser::class => ['*'],
],
],
'viewProject' => [
'components' => [
\App\Models\Project::class => ['find'],
\App\Models\ProjectUser::class => ['find'],
],
],
],
'roles' => [
'admin' => [
'features' => ['manageProject'],
],
'researcher' => [
'features' => ['viewProject'],
],
],
],
6. Call The Resource
Exact URLs depend on your route config. With the default module route shape, these actions are available:
/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
Create a project:
POST /api/project/create
Content-Type: application/json
{
"label": "Systematic Review 2026",
"status": "active",
"usernode": [
{
"userId": 10,
"type": "leader"
},
{
"userId": 11,
"type": "member"
}
]
}
Example success response shape:
{
"success": true,
"data": {
"id": 42,
"label": "Systematic Review 2026",
"status": "active",
"deleted": false
},
"messages": []
}
Example validation response shape:
{
"success": false,
"data": null,
"messages": [
{
"field": "label",
"type": "PresenceOf",
"message": "required"
}
]
}
Fetch projects with loaded users:
GET /api/project/find-with?filter[status]=active&order[id]=desc&limit=20
Example response shape:
{
"success": true,
"data": [
{
"id": 42,
"label": "Systematic Review 2026",
"status": "active",
"usernode": [
{
"id": 100,
"userId": 10,
"type": "leader"
}
]
}
]
}
The exact envelope can be customized by the app, but the important parts are: the controller policy defines allowed input/query fields, and eager loading keeps relation data out of lazy-loading loops.
7. Use Transformers For Stable Output
Use exposers for simple CRUD surfaces. Use transformers when public clients need a stable response contract.
<?php
namespace App\Modules\Api\Transformers;
use App\Models\Project;
use League\Fractal\Resource\Collection;
final class ProjectTransformer extends AbstractModelTransformer
{
public array $defaultIncludes = [
'usernode',
];
public function transform(?Project $project): array
{
if (!$project) {
return [];
}
return [
'id' => $project->getId(),
'label' => $project->getLabel(),
'status' => $project->getStatus(),
'deleted' => $project->isDeleted(),
'createdAt' => $project->getCreatedAt(),
'updatedAt' => $project->getUpdatedAt(),
];
}
public function includeUserNode(Project $project): Collection
{
return $this->includeCollectionIfLoaded(
$project,
'usernode',
new ProjectUserTransformer()
);
}
}
The include only emits loaded relations. Pair transformers with findWith() or controller initializeWith() so API responses stay predictable and efficient.
What You Got
With this setup, the resource has:
- generated model accessors, validation, and relationships;
- concrete model methods for business rules;
- REST list/detail/save behavior;
- nested relation writes through
usernode; - eager-loaded user information for
find-withresponses; - transformer-ready output for stable API contracts;
- role policy in config;
- row-level project scoping in the controller.
Verify The Resource
Exercise one success and one failure for each public capability:
curl --include 'http://127.0.0.1:8000/api/project/find?filter[status]=active'
curl --include 'http://127.0.0.1:8000/api/project/find-with?with=UserList'
curl --include \
--header 'Content-Type: application/json' \
--data '{"label":"Docs portal","status":"draft"}' \
http://127.0.0.1:8000/api/project/create
Adapt routes and authentication headers to the application. Also verify:
- unsupported fields cannot be saved or exposed;
- invalid enum and uniqueness values return useful validation messages;
- non-super users only see allowed projects;
- a requested relationship is loaded in batches rather than once per row;
- delete and restore follow the intended soft-delete policy.
Automate those checks as integration tests after the first manual proof.
After Each Schema Change
Review all connected pieces:
- migration
- generated abstract model diff
- concrete model behavior
- REST save/filter/search/expose policies
- eager-loading relation graph
- transformer includes
- permission config
- row-level permission conditions
- focused tests
Use Developer Cookbook for workflow-action and testing recipes, and Troubleshooting when a request does not reach the expected layer.