Skip to main content
A resource replaces the 15-class CRUD scaffold (5 controllers + 5 requests + 5 responses) with one resource + five controllers. All request schemas, response envelopes, and OpenAPI docs are derived from the resource declaration. This how-to wires up Animal as a fully protected resource (JWT + scopes).

Directory layout

src/
├── Http/Controller/Animal/
│   ├── CreateAnimalController.php
│   ├── ViewAnimalController.php
│   ├── UpdateAnimalController.php
│   ├── DeleteAnimalController.php
│   └── ListAnimalController.php
└── Resource/
    └── AnimalResource.php

1. Declare the resource

<?php

declare(strict_types=1);

namespace App\Resource;

use apivalk\apivalk\Documentation\OpenAPI\Object\TagObject;
use apivalk\apivalk\Documentation\Property\EnumProperty;
use apivalk\apivalk\Documentation\Property\FloatProperty;
use apivalk\apivalk\Documentation\Property\StringProperty;
use apivalk\apivalk\Resource\AbstractResource;
use apivalk\apivalk\Router\Route\Filter\EnumFilter;
use apivalk\apivalk\Router\Route\Filter\StringFilter;
use apivalk\apivalk\Router\Route\Sort\Sort;

class AnimalResource extends AbstractResource
{
    public function getName(): string
    {
        return 'animal';
    }

    public function tags(): array
    {
        return [new TagObject('Animals', 'Animal management')];
    }

    public function availableFilters(): array
    {
        return [
            EnumFilter::equals(new EnumProperty('status', 'Status', ['active', 'archived'])),
            StringFilter::contains(new StringProperty('name', 'Name contains')),
        ];
    }

    public function availableSortings(): array
    {
        return [Sort::asc('name'), Sort::desc('created_at')];
    }

    public function excludeFromMode(string $mode): array
    {
        if ($mode === self::MODE_CREATE) {
            return ['animal_uuid']; // server generates the identifier
        }

        if ($mode === self::MODE_LIST) {
            return ['weight']; // keep list payloads small
        }

        return [];
    }

    protected function init(): void
    {
        $this->addProperty(new StringProperty('animal_uuid', 'Unique identifier of the animal'));
        $this->addProperty(new StringProperty('name', 'Animal name'));
        $this->addProperty(new EnumProperty('status', 'Lifecycle status', ['active', 'archived']));
        $this->addProperty((new FloatProperty('weight', 'Weight in kg'))->setIsRequired(false));
    }
}
The identifier (animal_uuid) is a regular property declared first by convention. Excluding it from MODE_CREATE removes it from the create request body documentation — the server generates it.

2. Wire the five controllers

Each controller is a thin subclass. You implement getResourceClass(), buildRoute(), and __invoke().

Create

<?php

declare(strict_types=1);

namespace App\Http\Controller\Animal;

use apivalk\apivalk\Http\Controller\Resource\AbstractCreateResourceController;
use apivalk\apivalk\Http\Request\ApivalkRequestInterface;
use apivalk\apivalk\Http\Response\AbstractApivalkResponse;
use apivalk\apivalk\Http\Response\Resource\ResourceCreatedResponse;
use apivalk\apivalk\Router\Route\Route;
use apivalk\apivalk\Security\RouteAuthorization;
use App\Domain\Animal\AnimalRepository;
use App\Resource\AnimalResource;

/**
 * @extends AbstractCreateResourceController<AnimalResource>
 */
final class CreateAnimalController extends AbstractCreateResourceController
{
    /** @var AnimalRepository */
    private $repository;

    public function __construct(AnimalRepository $repository)
    {
        $this->repository = $repository;
    }

    public static function getResourceClass(): string
    {
        return AnimalResource::class;
    }

    protected static function buildRoute(): Route
    {
        return Route::post('/api/v1/animals')
            ->description('Create animal')
            ->routeAuthorization(new RouteAuthorization('BearerAuth', ['animal'], ['animal:create']));
    }

    public function __invoke(ApivalkRequestInterface $request): AbstractApivalkResponse
    {
        $animal = $this->getResource($request); // typed AnimalResource, body only
        $animal->animal_uuid = Uuid::v4(); // generate UUID
        $this->repository->persist($animal)

        return new ResourceCreatedResponse($animal);
    }
}

View

<?php

declare(strict_types=1);

namespace App\Http\Controller\Animal;

use apivalk\apivalk\Documentation\Property\StringProperty;
use apivalk\apivalk\Http\Controller\Resource\AbstractViewResourceController;
use apivalk\apivalk\Http\Request\ApivalkRequestInterface;
use apivalk\apivalk\Http\Response\AbstractApivalkResponse;
use apivalk\apivalk\Http\Response\NotFoundApivalkResponse;
use apivalk\apivalk\Http\Response\Resource\ResourceViewResponse;
use apivalk\apivalk\Router\Route\Route;
use apivalk\apivalk\Security\RouteAuthorization;
use App\Domain\Animal\AnimalRepository;
use App\Resource\AnimalResource;

/**
 * @extends AbstractViewResourceController<AnimalResource>
 */
final class ViewAnimalController extends AbstractViewResourceController
{
    /** @var AnimalRepository */
    private $repository;

    public function __construct(AnimalRepository $repository)
    {
        $this->repository = $repository;
    }

    public static function getResourceClass(): string
    {
        return AnimalResource::class;
    }

    protected static function buildRoute(): Route
    {
        return Route::get('/api/v1/animals/{animal_uuid}')
            ->pathProperty(new StringProperty('animal_uuid', 'Animal UUID'))
            ->description('Get animal')
            ->routeAuthorization(new RouteAuthorization('BearerAuth', ['animal'], ['animal:read']));
    }

    /**
     * @param AnimalViewRequest $request
     */
    public function __invoke(ApivalkRequestInterface $request): AbstractApivalkResponse
    {
        $uuid = $request->path()->animal_uuid;
        $row  = $this->repository->findByUuid($uuid);

        if ($row === null) {
            return new NotFoundApivalkResponse();
        }

        return new ResourceViewResponse(AnimalResource::byArray($row));
    }
}

Update

<?php

declare(strict_types=1);

namespace App\Http\Controller\Animal;

use apivalk\apivalk\Documentation\Property\StringProperty;
use apivalk\apivalk\Http\Controller\Resource\AbstractUpdateResourceController;
use apivalk\apivalk\Http\Request\ApivalkRequestInterface;
use apivalk\apivalk\Http\Response\AbstractApivalkResponse;
use apivalk\apivalk\Http\Response\Resource\ResourceUpdatedResponse;
use apivalk\apivalk\Router\Route\Route;
use apivalk\apivalk\Security\RouteAuthorization;
use App\Domain\Animal\AnimalRepository;
use App\Resource\AnimalResource;

/**
 * @extends AbstractUpdateResourceController<AnimalResource>
 */
final class UpdateAnimalController extends AbstractUpdateResourceController
{
    /** @var AnimalRepository */
    private $repository;

    public function __construct(AnimalRepository $repository)
    {
        $this->repository = $repository;
    }

    public static function getResourceClass(): string
    {
        return AnimalResource::class;
    }

    protected static function buildRoute(): Route
    {
        return Route::patch('/api/v1/animals/{animal_uuid}')
            ->pathProperty(new StringProperty('animal_uuid', 'Animal UUID'))
            ->description('Update animal')
            ->routeAuthorization(new RouteAuthorization('BearerAuth', ['animal'], ['animal:update']));
    }

    /**
     * @param AnimalUpdateRequest $request
     */
    public function __invoke(ApivalkRequestInterface $request): AbstractApivalkResponse
    {
        // body fields + animal_uuid auto-set from path (matched by property name)
        $animal = $this->getResource($request);

        $this->repository->update($animal);

        return new ResourceUpdatedResponse($animal);
    }
}

Delete

<?php

declare(strict_types=1);

namespace App\Http\Controller\Animal;

use apivalk\apivalk\Documentation\Property\StringProperty;
use apivalk\apivalk\Http\Controller\Resource\AbstractDeleteResourceController;
use apivalk\apivalk\Http\Request\ApivalkRequestInterface;
use apivalk\apivalk\Http\Response\AbstractApivalkResponse;
use apivalk\apivalk\Http\Response\DeletedApivalkResponse;
use apivalk\apivalk\Router\Route\Route;
use apivalk\apivalk\Security\RouteAuthorization;
use App\Domain\Animal\AnimalRepository;
use App\Resource\AnimalResource;

/**
 * @extends AbstractDeleteResourceController<AnimalResource>
 */
final class DeleteAnimalController extends AbstractDeleteResourceController
{
    /** @var AnimalRepository */
    private $repository;

    public function __construct(AnimalRepository $repository)
    {
        $this->repository = $repository;
    }

    public static function getResourceClass(): string
    {
        return AnimalResource::class;
    }

    protected static function buildRoute(): Route
    {
        return Route::delete('/api/v1/animals/{animal_uuid}')
            ->pathProperty(new StringProperty('animal_uuid', 'Animal UUID'))
            ->description('Delete animal')
            ->routeAuthorization(new RouteAuthorization('BearerAuth', ['animal'], ['animal:delete']));
    }

    /**
     * @param AnimalDeleteRequest $request
     */
    public function __invoke(ApivalkRequestInterface $request): AbstractApivalkResponse
    {
        $this->repository->delete($request->path()->animal_uuid);

        return new DeletedApivalkResponse();
    }
}

List

<?php

declare(strict_types=1);

namespace App\Http\Controller\Animal;

use apivalk\apivalk\Http\Controller\Resource\AbstractListResourceController;
use apivalk\apivalk\Http\Request\ApivalkRequestInterface;
use apivalk\apivalk\Http\Response\AbstractApivalkResponse;
use apivalk\apivalk\Http\Response\Pagination\PagePaginationResponse;
use apivalk\apivalk\Http\Response\Resource\ResourceListResponse;
use apivalk\apivalk\Router\Route\Pagination\Pagination;
use apivalk\apivalk\Router\Route\Route;
use apivalk\apivalk\Security\RouteAuthorization;
use App\Domain\Animal\AnimalRepository;
use App\Resource\AnimalResource;

/**
 * @extends AbstractListResourceController<AnimalResource>
 */
final class ListAnimalController extends AbstractListResourceController
{
    /** @var AnimalRepository */
    private $repository;

    public function __construct(AnimalRepository $repository)
    {
        $this->repository = $repository;
    }

    public static function getResourceClass(): string
    {
        return AnimalResource::class;
    }

    // tags(), availableFilters(), availableSortings() injected from AnimalResource automatically
    protected static function buildRoute(): Route
    {
        return Route::get('/api/v1/animals')
            ->description('List animals')
            ->pagination(Pagination::page()->setMaxLimit(50))
            ->routeAuthorization(new RouteAuthorization('BearerAuth', ['animal'], ['animal:read']));
    }

    /**
     * @param AnimalListRequest $request
     */
    public function __invoke(ApivalkRequestInterface $request): AbstractApivalkResponse
    {
        $paginator = $request->paginator();
        $filters   = $request->filtering();
        $sorting   = $request->sorting();

        $rows  = $this->repository->findPage($filters, $sorting, $paginator);
        $total = $this->repository->countForFilters($filters);

        $resources = [];
        foreach ($rows as $row) {
            $resources[] = AnimalResource::byArray($row);
        }

        $totalPages = (int)\ceil($total / $paginator->getLimit());

        return new ResourceListResponse(
            $resources,
            new PagePaginationResponse(
                $paginator->getPage(),
                $paginator->getLimit(),
                $paginator->getPage() < $totalPages,
                $totalPages
            )
        );
    }
}

What the framework handles for you

  • Body / path / filter / sort validationRequestValidationMiddleware validates against the runtime documentation derived from AnimalResource. Unknown ?order_by=hacked_field → 422 before your controller runs.
  • Response envelopeResource*Response classes emit the standard {"data": ...} (and "pagination" for list). No toArray() to write.
  • Per-mode field visibilityexcludeFromMode() hides animal_uuid from the create body and weight from list responses; the rest of the endpoints still see both.
  • OpenAPI — all five operations are generated from the single resource. Add a field to AnimalResource::init() and every operation updates at once.

Nested resources

Because buildRoute() is fully explicit, nested URLs require no special setup — add more path segments and declare each one via ->pathProperty():
// GET /api/v1/animals/{animal_uuid}/kids/{kid_uuid}
protected static function buildRoute(): Route
{
    return Route::get('/api/v1/animals/{animal_uuid}/kids/{kid_uuid}')
        ->pathProperty(new StringProperty('animal_uuid', 'Parent animal UUID'))
        ->pathProperty(new StringProperty('kid_uuid', 'Kid UUID'))
        ->description('Get animal kid')
        ->routeAuthorization(new RouteAuthorization('BearerAuth', ['animal'], ['animal:read']));
}

public function __invoke(ApivalkRequestInterface $request): AbstractApivalkResponse
{
    $animalUuid = $request->path()->animal_uuid;
    $kidUuid    = $request->path()->kid_uuid;

    // ...
}
Both path parameters are validated, documented in OpenAPI, and typed in the path bag.

Optional: IDE autocomplete via the DocBlock generator

Run the docblock generator once. See Generate OpenAPI and docblocks for the script. It produces:
  • @property annotations on AnimalResource — so $animal->name, $animal->status etc. autocomplete.
  • A typed request class per controller that has path params, sorting, filtering, or pagination:
    • AnimalViewRequest / AnimalDeleteRequest — typed path()
    • AnimalUpdateRequest — typed path()
    • AnimalListRequest — typed sorting(), filtering(), paginator()
To activate autocomplete in a controller, add a @param annotation on __invoke with the generated class:
/**
 * @param AnimalViewRequest $request
 */
public function __invoke(ApivalkRequestInterface $request): AbstractApivalkResponse
{
    $uuid = $request->path()->animal_uuid; // autocompletes
    // ...
}