CrudController - viames/pair GitHub Wiki
Pair framework: CrudController
Pair\Api\CrudController extends ApiController and auto-exposes REST-style CRUD endpoints for ActiveRecord models.
In Pair v4 the normal output path is explicit:
- use
readModelfor the preferred public response contract - keep
resourceonly as a migration bridge when needed - do not rely on raw
ActiveRecord::toArray()as the normal API contract
Main method: crud(string $slug, string $modelClass, array|CrudResourceConfig|null $config = null): void
Register resources in _init():
<?php
namespace App\Modules\Api;
use Pair\Api\CrudController as BaseCrudController;
use App\Orm\Faq;
use App\Api\ReadModels\FaqReadModel;
class ApiController extends BaseCrudController {
protected function _init(): void
{
parent::_init();
$this->crud('faqs', Faq::class, [
'readModel' => FaqReadModel::class,
]);
}
}
This generates the following endpoints for faqs:
GET /api/faqsGET /api/faqs/{id}POST /api/faqsPUT /api/faqs/{id}andPATCH /api/faqs/{id}DELETE /api/faqs/{id}
If $config is null and the model exposes getApiConfig(), CrudController uses that model-level API configuration automatically.
Array configs remain fully supported. Internally, Pair v4 normalizes every resource config through CrudResourceConfig, so defaults and config keys are handled in one place.
How each request is handled
List endpoint: GET /api/{slug}
The list flow applies:
- filtering, sorting, searching, and pagination through QueryFilter
- optional sparse fieldsets via
fields - optional includes via
include
The response is built through ApiResponse::paginatedResponse() using the configured read contract.
On the migrated v4 success path, GET /api/{slug} now returns an explicit JsonResponse.
Show endpoint: GET /api/{slug}/{id}
The show flow:
- loads the object with
find($id) - returns an explicit
ApiErrorResponsewithNOT_FOUNDif it does not exist - applies
fieldsandincludeif requested and allowed - maps the record through
readModelorresource - now returns an explicit
JsonResponseon the migrated v4 success path
Create endpoint: POST /api/{slug}
The create flow:
- requires JSON content
- returns an explicit
ApiErrorResponsewithUNSUPPORTED_MEDIA_TYPEorBAD_REQUESTwhen the request body cannot be accepted - optionally validates against
rules.create - returns an explicit
ApiErrorResponsewithINVALID_FIELDSwhenrules.createrejects the payload on the migrated v4 path - writes only properties that exist in the model binds
- returns the created resource with HTTP
201using the configured explicit response contract - now returns an explicit
JsonResponseon the migrated v4 success path
Update endpoint: PUT or PATCH /api/{slug}/{id}
The update flow:
- returns an explicit
ApiErrorResponsewithBAD_REQUESTwhen no resource ID is provided - returns an explicit
ApiErrorResponsewithNOT_FOUNDif the resource does not exist - requires JSON content
- returns an explicit
ApiErrorResponsewithUNSUPPORTED_MEDIA_TYPEorBAD_REQUESTwhen the request body cannot be accepted - optionally validates against
rules.update - returns an explicit
ApiErrorResponsewithINVALID_FIELDSwhenrules.updaterejects the payload on the migrated v4 path - updates only bindable properties
- returns the transformed resource after update
- now returns an explicit
JsonResponseon the migrated v4 success path
Delete endpoint: DELETE /api/{slug}/{id}
The delete flow:
- returns an explicit
ApiErrorResponsewithBAD_REQUESTwhen no resource ID is provided - loads the object
- returns an explicit
ApiErrorResponsewithNOT_FOUNDif the resource does not exist - calls
isDeletable()when available - returns an explicit
ApiErrorResponsewithCONFLICTwhen the resource cannot be deleted - returns
204on success - now returns an explicit
JsonResponseon the migrated v4 success path
Response contracts
Preferred: readModel
readModel should point to a readonly class implementing Pair's read-model contract.
This same contract can also be reused for HTML state when needed.
Example:
$this->crud('users', \App\Orm\User::class, [
'readModel' => \App\Api\ReadModels\UserReadModel::class,
]);
Minimal read model:
namespace App\Api\ReadModels;
use Pair\Data\ArraySerializableData;
use Pair\Data\MapsFromRecord;
use Pair\Data\ReadModel;
use Pair\Orm\ActiveRecord;
/**
* Public user payload returned by CrudController.
*/
final readonly class UserReadModel implements ReadModel, MapsFromRecord {
use ArraySerializableData;
/**
* Build the public user payload.
*/
public function __construct(
public int $id,
public string $name,
public ?string $email
) {}
/**
* Build the payload from the persistence model.
*/
public static function fromRecord(ActiveRecord $record): static {
return new self(
(int)$record->id,
(string)$record->name,
$record->email ? (string)$record->email : null
);
}
/**
* Export the public user payload.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
];
}
}
Legacy bridge: resource
If a resource config defines a resource class and that class exists, CrudController still uses it.
This remains supported to keep migrations practical, but it is no longer the preferred Pair v4 design.
Includes and related read models
When using includes, Pair v4 can also map related data through explicit read contracts:
$this->crud('users', \App\Orm\User::class, [
'readModel' => \App\Api\ReadModels\UserReadModel::class,
'includes' => ['group'],
'includeReadModels' => [
'group' => \App\Api\ReadModels\GroupReadModel::class,
],
]);
For collection responses with expensive relations, configure includePreloader with a CrudIncludePreloader implementation:
$this->crud('users', \App\Orm\User::class, [
'readModel' => \App\Api\ReadModels\UserReadModel::class,
'includes' => ['group'],
'includeReadModels' => [
'group' => \App\Api\ReadModels\GroupReadModel::class,
],
'includePreloader' => \App\Api\Preloaders\UserIncludePreloader::class,
]);
The preloader runs once for the collection and returns relation objects keyed by parent. CrudController then applies the same include transformers as the non-preloaded path, so response shapes stay unchanged.
OpenAPI behavior
OpenAPI generation now follows the same explicit contract.
When a CRUD resource defines readModel, the generated response schema is built from that read model instead of from the persistence class.
Write schemas still follow the persistence model and validation rules.
High-value config keys
Common config keys used with crud():
rules.createrules.updatefilterablesortablesearchabledefaultSortperPagemaxPerPageincludesreadModelincludeReadModelsincludePreloaderresource
Typed config
CrudController accepts either the usual array config or a Pair\Api\CrudResourceConfig instance.
use Pair\Api\CrudResourceConfig;
$this->crud('users', \App\Orm\User::class, CrudResourceConfig::fromArray([
'readModel' => \App\Api\ReadModels\UserReadModel::class,
'perPage' => 25,
]));
getResourceConfig() still returns the legacy array shape so existing inspection code and OpenAPI integration remain source-compatible.
Common pitfalls
- exposing internal fields in
filterableorsortable - forgetting
readModeland falling back to a migration-onlyresource - assuming includes are automatic without declaring them in config
- using
includePreloaderto serialize data instead of returning relation objects - assuming persistence objects are still the intended public contract
See also: API, ApiController, ApiExposable, ReadModel, RecordMapper, CrudIncludePreloader, CrudResourceConfig, Upgrade-to-v4, SpecGenerator