Developer Cookbook
These focused recipes are designed to be copied, renamed, and adapted. Each recipe identifies the ownership boundary so the example remains maintainable as the application grows.
Note
Namespace and route conventions can differ between application skeletons. Keep the Phalcon Kit API usage, but adapt App\... namespaces and URLs to the project’s module layout.
Return A JSON Health Response
Use the non-model-backed Rest controller for health checks, webhooks, and workflow endpoints that do not represent CRUD over one model.
<?php
namespace App\Modules\Api\Controllers;
use Phalcon\Http\ResponseInterface;
use PhalconKit\Mvc\Controller\Rest;
final class HealthController extends Rest
{
public function indexAction(): ResponseInterface
{
return $this->setRestResponse([
'ok' => true,
'time' => new \DateTimeImmutable()->format(DATE_ATOM),
]);
}
}
Try it:
curl --include http://127.0.0.1:8000/api/health
Use the model-backed application base controller only for resources that need the REST query/save policy surface.
Register An Application Service
Services shared by controllers, tasks, or other services belong in a provider.
<?php
namespace App\Provider\Report;
use App\Service\ReportExporter;
use PhalconKit\Di\DiInterface;
use PhalconKit\Provider\AbstractServiceProvider;
final class ServiceProvider extends AbstractServiceProvider
{
protected string $serviceName = 'reportExporter';
public function register(DiInterface $di): void
{
$di->setShared($this->getName(), static function () use ($di) {
return new ReportExporter(
$di->getTyped('db', \Phalcon\Contracts\Db\Adapter\Adapter::class),
$di->getTyped('logger', \Phalcon\Contracts\Logger\Logger::class)
);
});
}
}
Register it in app config:
'providers' => [
\App\Provider\Report\ServiceProvider::class =>
\App\Provider\Report\ServiceProvider::class,
],
Resolve by the stable service name from an injectable controller or task:
$file = $this->reportExporter->exportProject($projectId);
Constructor injection remains preferable inside plain domain services. The DI provider is the composition boundary, not a reason to make every class container-aware.
Load A Relationship Graph Efficiently
Use findWith() or findFirstWith() when the graph is known by server code:
$projects = Project::findWith(
['OwnerEntity', 'TaskList.AssigneeEntity'],
[
'conditions' => 'status = :status:',
'bind' => ['status' => 'active'],
'order' => 'createdAt DESC',
'limit' => 25,
]
);
For one record:
$project = Project::findFirstWith(
['OwnerEntity', 'TaskList'],
[
'conditions' => 'id = :id:',
'bind' => ['id' => $projectId],
]
);
Use aliases generated from the actual relationships. Do not guess an alias from the table name—inspect the generated abstract model after scaffolding.
Add A Workflow Action
Business transitions are clearer as named actions than as unrestricted field updates.
<?php
namespace App\Modules\Api\Controllers;
use Phalcon\Http\ResponseInterface;
use PhalconKit\Mvc\Controller\Attributes\PermissionFeature;
final class ProjectController extends AbstractController
{
#[PermissionFeature('project.manage')]
public function archiveProjectAction(): ResponseInterface
{
$project = $this->findFirst();
if (!$project) {
return $this->setRestErrorResponse(404, response: false);
}
$project->archive();
if (!$project->save()) {
return $this->setRestErrorResponse(
$this->getRestActionFailureStatusCode($project->getMessages()),
response: $project->getMessages()
);
}
return $this->setRestResponse(true);
}
}
Keep the state-transition rule in Project::archive(). The controller owns request lookup, authorization, response mapping, and HTTP status.
Expose A Safe Distinct-Value Endpoint
Distinct values are useful for filters and autocomplete controls, but the endpoint is closed until a controller explicitly approves fields.
public function initializeDistinctActionFields(): void
{
$this->setDistinctActionFields([
'status',
'type',
'ownerEmail' => 'Owner.email',
]);
}
Clients can then request:
GET /api/project/distinct?field=status
GET /api/project/distinct?field=ownerEmail&search=example.com
The endpoint reuses normal query filters, joins, identity conditions, and permission policy. Do not expose sensitive or high-cardinality fields simply because they are filterable.
Return A Stable Public Representation
Use a transformer when API output should not mirror the model’s internal field names or relationship layout.
<?php
namespace App\Transformers;
use App\Models\Project;
use League\Fractal\TransformerAbstract;
final class ProjectTransformer extends TransformerAbstract
{
public function transform(Project $project): array
{
return [
'id' => $project->getId(),
'name' => $project->getName(),
'status' => $project->getStatus(),
'links' => [
'self' => '/api/project/' . $project->getId(),
],
];
}
}
Transformers are especially useful for long-lived clients. The model can evolve without forcing its database naming and helper methods into the public contract.
Add A CLI Task
CLI tasks use the same configured services as the HTTP application:
<?php
namespace App\Modules\Cli\Tasks;
use PhalconKit\Cli\Task;
final class ProjectTask extends Task
{
public function archiveInactiveAction(int $days = 90): void
{
$count = $this->projectArchiver->archiveInactive($days);
$this->logger->info('Archived inactive projects', [
'days' => $days,
'count' => $count,
]);
echo "Archived {$count} projects", PHP_EOL;
}
}
Route syntax depends on the application CLI entrypoint. A common invocation is:
php cli project archive-inactive 90
Keep orchestration in the task and reusable business behavior in a service so HTTP actions, scheduled jobs, and tests can share it.
Add A Focused Model Test
Test domain behavior in the concrete model without asserting generated getters one by one:
public function testDraftProjectCanBeActivated(): void
{
$project = new Project();
$project->setStatus('draft');
$project->activate();
self::assertSame('active', $project->getStatus());
}
public function testArchivedProjectCannotBeActivated(): void
{
$project = new Project();
$project->setStatus('archived');
$this->expectException(\DomainException::class);
$project->activate();
}
Add database-backed tests when the behavior depends on relationships, transactions, indexes, generated defaults, or adapter-specific SQL.
Recipe Selection Guide
| Need | Put it here |
|---|---|
| One model’s invariant | Concrete model |
| Several models or an external API | Domain/application service |
| Shared dependency construction | Service provider |
| HTTP input, query policy, and response | Controller |
| Stable client-facing shape | Transformer |
| Record visibility | Permission/query condition |
| Operational orchestration | CLI or WebSocket task |
For the complete concepts behind these recipes, continue with Architecture, Configuration, REST APIs, and Models And Eager Loading.