Skip to content

CrudController

Viames Marino edited this page Mar 26, 2026 · 3 revisions

Pair framework: CrudController

Pair\Api\CrudController extends ApiController and auto-exposes REST-style CRUD endpoints for ActiveRecord models.

Use it when your API module mainly maps HTTP verbs to database-backed resources and you want filtering, pagination, sparse fieldsets, and resource transformation without writing the same controller code repeatedly.

Main method: crud(string $slug, string $modelClass, ?array $config = null): void

Register resources in _init():

<?php

namespace App\Modules\Api;

use Pair\Api\CrudController as BaseCrudController;
use App\Orm\Faq;

class ApiController extends BaseCrudController {

    protected function _init(): void
    {
        parent::_init();
        $this->crud('faqs', Faq::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.

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 sent through ApiResponse::paginated().

Show endpoint: GET /api/{slug}/{id}

The show flow:

  • loads the object with find($id)
  • returns NOT_FOUND if it does not exist
  • applies fields and include if requested and allowed

Create endpoint: POST /api/{slug}

The create flow:

  • requires JSON content
  • optionally validates against rules.create
  • writes only properties that exist in the model binds
  • returns the created resource with HTTP 201

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

The update flow:

  • requires JSON content
  • optionally validates against rules.update
  • updates only bindable properties
  • returns the transformed resource after update

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

The delete flow:

  • loads the object
  • calls isDeletable() when available
  • returns 204 on success

Resource transformation

If a resource config defines a resource class and that class exists, CrudController uses it to transform the output. Otherwise it falls back to ActiveRecord::toArray().

That makes it easy to keep a stable public contract even when the model contains internal fields.

Example:

$this->crud('users', \App\Orm\User::class, [
    'resource' => \App\Api\Resources\UserResource::class,
]);

High-value config keys

Common config keys used with crud():

  • rules.create
  • rules.update
  • filterable
  • sortable
  • searchable
  • defaultSort
  • perPage
  • maxPerPage
  • includes
  • resource

Example with rules, includes, and transformer:

$this->crud('users', \App\Orm\User::class, [
    'rules' => [
        'create' => [
            'email' => 'required|email',
            'name' => 'required|string|max:120',
        ],
        'update' => [
            'email' => 'email',
            'name' => 'string|max:120',
        ],
    ],
    'filterable' => ['email', 'enabled', 'groupId'],
    'sortable' => ['id', 'email', 'createdAt'],
    'searchable' => ['email', 'name', 'surname'],
    'defaultSort' => '-id',
    'includes' => ['group'],
    'resource' => \App\Api\Resources\UserResource::class,
]);

Practical examples

Register multiple resources with different policies

protected function _init(): void
{
    parent::_init();

    $this->crud('users', \App\Orm\User::class, [
        'filterable' => ['email', 'enabled', 'groupId'],
        'sortable' => ['id', 'email', 'createdAt'],
        'searchable' => ['email', 'name', 'surname'],
        'defaultSort' => '-id',
        'perPage' => 20,
        'maxPerPage' => 100,
        'includes' => ['group'],
    ]);

    $this->crud('orders', \App\Orm\Order::class, [
        'filterable' => ['status', 'customerId'],
        'sortable' => ['id', 'createdAt', 'total'],
        'defaultSort' => '-createdAt',
    ]);
}

Add a custom action next to auto-CRUD

CrudController only intercepts missing actions that match a registered slug. Regular controller methods still work:

public function statsAction(): void
{
    $this->requireAuth();

    \Pair\Api\ApiResponse::respond([
        'users' => \App\Orm\User::countAllObjects(),
        'orders' => \App\Orm\Order::countAllObjects(),
    ]);
}

Includes are getter-driven

If you allow include=group, the model should expose a matching getter such as getGroup():

$this->crud('users', \App\Orm\User::class, [
    'includes' => ['group'],
]);

At runtime, CrudController calls getGroup() and appends the transformed related data when the include is requested.

Secondary methods (short reference)

  • getRegisteredResources(): array returns the list of registered slugs.
  • getResourceConfig(string $slug): ?array returns the full registration entry for that slug, including both class and config.
  • __call(mixed $name, mixed $arguments): void routes missing actions to CRUD handlers when the action name matches a registered slug; otherwise it falls back to the normal ApiController NOT_FOUND behavior.

Runtime inspection example:

$slugs = $this->getRegisteredResources();
$usersRegistration = $this->getResourceConfig('users');

// ['class' => ..., 'config' => ...]

Common pitfalls

  • Registering a model class that is not a valid ActiveRecord.
  • Making filterable too broad and exposing internal columns.
  • Forgetting a resource transformer when you need a strict public contract.
  • Assuming includes are automatic without declaring them in config.
  • Expecting fields or include to bypass your transformer; they apply after or around the configured transformation flow.

See also: API, ApiController, QueryFilter, Resource, ApiExposable.

Clone this wiki locally