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 readModel for the preferred public response contract
  • keep resource only 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/faqs
  • GET /api/faqs/{id}
  • POST /api/faqs
  • PUT /api/faqs/{id} and PATCH /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 ApiErrorResponse with NOT_FOUND if it does not exist
  • applies fields and include if requested and allowed
  • maps the record through readModel or resource
  • now returns an explicit JsonResponse on the migrated v4 success path

Create endpoint: POST /api/{slug}

The create flow:

  • requires JSON content
  • returns an explicit ApiErrorResponse with UNSUPPORTED_MEDIA_TYPE or BAD_REQUEST when the request body cannot be accepted
  • optionally validates against rules.create
  • returns an explicit ApiErrorResponse with INVALID_FIELDS when rules.create rejects the payload on the migrated v4 path
  • writes only properties that exist in the model binds
  • returns the created resource with HTTP 201 using the configured explicit response contract
  • now returns an explicit JsonResponse on the migrated v4 success path

Update endpoint: PUT or PATCH /api/{slug}/{id}

The update flow:

  • returns an explicit ApiErrorResponse with BAD_REQUEST when no resource ID is provided
  • returns an explicit ApiErrorResponse with NOT_FOUND if the resource does not exist
  • requires JSON content
  • returns an explicit ApiErrorResponse with UNSUPPORTED_MEDIA_TYPE or BAD_REQUEST when the request body cannot be accepted
  • optionally validates against rules.update
  • returns an explicit ApiErrorResponse with INVALID_FIELDS when rules.update rejects the payload on the migrated v4 path
  • updates only bindable properties
  • returns the transformed resource after update
  • now returns an explicit JsonResponse on the migrated v4 success path

Delete endpoint: DELETE /api/{slug}/{id}

The delete flow:

  • returns an explicit ApiErrorResponse with BAD_REQUEST when no resource ID is provided
  • loads the object
  • returns an explicit ApiErrorResponse with NOT_FOUND if the resource does not exist
  • calls isDeletable() when available
  • returns an explicit ApiErrorResponse with CONFLICT when the resource cannot be deleted
  • returns 204 on success
  • now returns an explicit JsonResponse on 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.create
  • rules.update
  • filterable
  • sortable
  • searchable
  • defaultSort
  • perPage
  • maxPerPage
  • includes
  • readModel
  • includeReadModels
  • includePreloader
  • resource

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 filterable or sortable
  • forgetting readModel and falling back to a migration-only resource
  • assuming includes are automatic without declaring them in config
  • using includePreloader to 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