The resource-based approach is the default for CRUD. This guide is for the cases where it doesn’t fit — e.g. endpoints don’t share the same payload shape, you need custom request bodies per operation, or you need custom response envelopes. We’ll build CRUD for aDocumentation Index
Fetch the complete documentation index at: https://docs.apivalk.com/llms.txt
Use this file to discover all available pages before exploring further.
Pet: Create, View, Update, Delete. That’s 4 controllers + 4 requests + 4 responses = 12 classes. (List is a natural fifth — add it the same way.)
Directory layout
src/Http/
├── Controller/Pet/
│ ├── CreatePetController.php
│ ├── ViewPetController.php
│ ├── UpdatePetController.php
│ └── DeletePetController.php
├── Request/Pet/
│ ├── CreatePetRequest.php
│ ├── ViewPetRequest.php
│ ├── UpdatePetRequest.php
│ └── DeletePetRequest.php
└── Response/Pet/
├── CreatePetResponse.php
├── ViewPetResponse.php
├── UpdatePetResponse.php
└── DeletePetResponse.php
Shared pieces
Everything below assumes JWT auth using the sameBearerAuth scheme across all four routes — swap in your authenticator of choice.
use apivalk\apivalk\Security\RouteAuthorization;
$readAuth = new RouteAuthorization('BearerAuth', ['pet'], ['pet:read']);
$writeAuth = new RouteAuthorization('BearerAuth', ['pet'], ['pet:write']);
$deleteAuth = new RouteAuthorization('BearerAuth', ['pet'], ['pet:delete']);
getRoute() method — they’re split out here to keep the snippets compact.
Create
CreatePetRequest
<?php
declare(strict_types=1);
namespace App\Http\Request\Pet;
use apivalk\apivalk\Documentation\ApivalkRequestDocumentation;
use apivalk\apivalk\Documentation\Property\EnumProperty;
use apivalk\apivalk\Documentation\Property\FloatProperty;
use apivalk\apivalk\Documentation\Property\StringProperty;
use apivalk\apivalk\Http\Request\AbstractApivalkRequest;
class CreatePetRequest extends AbstractApivalkRequest
{
public static function getDocumentation(): ApivalkRequestDocumentation
{
$doc = new ApivalkRequestDocumentation();
$doc->addBodyProperty(new StringProperty('name', 'Pet name'));
$doc->addBodyProperty(new EnumProperty('species', 'Species', ['cat', 'dog', 'fish']));
$doc->addBodyProperty((new FloatProperty('weight', 'Weight in kg'))->setIsRequired(false));
return $doc;
}
}
CreatePetResponse
<?php
declare(strict_types=1);
namespace App\Http\Response\Pet;
use apivalk\apivalk\Documentation\ApivalkResponseDocumentation;
use apivalk\apivalk\Documentation\Property\StringProperty;
use apivalk\apivalk\Http\Response\AbstractApivalkResponse;
class CreatePetResponse extends AbstractApivalkResponse
{
/** @var array{pet_uuid: string, name: string, species: string} */
private $pet;
public function __construct(array $pet)
{
$this->pet = $pet;
}
public static function getStatusCode(): int
{
return self::HTTP_201_CREATED;
}
public static function getDocumentation(): ApivalkResponseDocumentation
{
$doc = new ApivalkResponseDocumentation();
$doc->setDescription('The newly created pet.');
$doc->addProperty(new StringProperty('pet_uuid', 'Unique identifier'));
$doc->addProperty(new StringProperty('name', 'Pet name'));
$doc->addProperty(new StringProperty('species', 'Species'));
return $doc;
}
public function toArray(): array
{
return $this->pet;
}
}
CreatePetController
<?php
declare(strict_types=1);
namespace App\Http\Controller\Pet;
use apivalk\apivalk\Http\Controller\AbstractApivalkController;
use apivalk\apivalk\Http\Request\ApivalkRequestInterface;
use apivalk\apivalk\Http\Response\AbstractApivalkResponse;
use apivalk\apivalk\Http\Response\BadRequestApivalkResponse;
use apivalk\apivalk\Http\Response\ForbiddenApivalkResponse;
use apivalk\apivalk\Router\Route\Route;
use apivalk\apivalk\Security\RouteAuthorization;
use App\Domain\Pet\PetRepository;
use App\Http\Request\Pet\CreatePetRequest;
use App\Http\Response\Pet\CreatePetResponse;
final class CreatePetController extends AbstractApivalkController
{
/** @var PetRepository */
private $repository;
public function __construct(PetRepository $repository)
{
$this->repository = $repository;
}
public static function getRoute(): Route
{
return Route::post('/api/v1/pets')
->summary('Create a pet')
->routeAuthorization(new RouteAuthorization('BearerAuth', ['pet'], ['pet:write']));
}
public static function getRequestClass(): string
{
return CreatePetRequest::class;
}
public static function getResponseClasses(): array
{
return [
CreatePetResponse::class,
BadRequestApivalkResponse::class,
ForbiddenApivalkResponse::class,
];
}
public function __invoke(ApivalkRequestInterface $request): AbstractApivalkResponse
{
$pet = $this->repository->create([
'name' => $request->body()->name,
'species' => $request->body()->species,
'weight' => $request->body()->weight,
]);
return new CreatePetResponse($pet);
}
}
View
ViewPetRequest
<?php
declare(strict_types=1);
namespace App\Http\Request\Pet;
use apivalk\apivalk\Documentation\ApivalkRequestDocumentation;
use apivalk\apivalk\Documentation\Property\StringProperty;
use apivalk\apivalk\Http\Request\AbstractApivalkRequest;
class ViewPetRequest extends AbstractApivalkRequest
{
public static function getDocumentation(): ApivalkRequestDocumentation
{
$doc = new ApivalkRequestDocumentation();
$doc->addPathProperty(new StringProperty('pet_uuid', 'Pet identifier'));
return $doc;
}
}
ViewPetResponse
<?php
declare(strict_types=1);
namespace App\Http\Response\Pet;
use apivalk\apivalk\Documentation\ApivalkResponseDocumentation;
use apivalk\apivalk\Documentation\Property\FloatProperty;
use apivalk\apivalk\Documentation\Property\StringProperty;
use apivalk\apivalk\Http\Response\AbstractApivalkResponse;
class ViewPetResponse extends AbstractApivalkResponse
{
/** @var array<string, mixed> */
private $pet;
public function __construct(array $pet)
{
$this->pet = $pet;
}
public static function getStatusCode(): int
{
return self::HTTP_200_OK;
}
public static function getDocumentation(): ApivalkResponseDocumentation
{
$doc = new ApivalkResponseDocumentation();
$doc->addProperty(new StringProperty('pet_uuid', 'Unique identifier'));
$doc->addProperty(new StringProperty('name', 'Pet name'));
$doc->addProperty(new StringProperty('species', 'Species'));
$doc->addProperty((new FloatProperty('weight', 'Weight in kg'))->setIsRequired(false));
return $doc;
}
public function toArray(): array
{
return $this->pet;
}
}
ViewPetController
<?php
declare(strict_types=1);
namespace App\Http\Controller\Pet;
use apivalk\apivalk\Http\Controller\AbstractApivalkController;
use apivalk\apivalk\Http\Request\ApivalkRequestInterface;
use apivalk\apivalk\Http\Response\AbstractApivalkResponse;
use apivalk\apivalk\Http\Response\ForbiddenApivalkResponse;
use apivalk\apivalk\Http\Response\NotFoundApivalkResponse;
use apivalk\apivalk\Router\Route\Route;
use apivalk\apivalk\Security\RouteAuthorization;
use App\Domain\Pet\PetRepository;
use App\Http\Request\Pet\ViewPetRequest;
use App\Http\Response\Pet\ViewPetResponse;
final class ViewPetController extends AbstractApivalkController
{
/** @var PetRepository */
private $repository;
public function __construct(PetRepository $repository)
{
$this->repository = $repository;
}
public static function getRoute(): Route
{
return Route::get('/api/v1/pets/{pet_uuid}')
->summary('Get a pet by UUID')
->routeAuthorization(new RouteAuthorization('BearerAuth', ['pet'], ['pet:read']));
}
public static function getRequestClass(): string
{
return ViewPetRequest::class;
}
public static function getResponseClasses(): array
{
return [
ViewPetResponse::class,
NotFoundApivalkResponse::class,
ForbiddenApivalkResponse::class,
];
}
public function __invoke(ApivalkRequestInterface $request): AbstractApivalkResponse
{
$pet = $this->repository->findByUuid($request->path()->pet_uuid);
if ($pet === null) {
return new NotFoundApivalkResponse();
}
return new ViewPetResponse($pet);
}
}
Update
UpdatePetRequest
<?php
declare(strict_types=1);
namespace App\Http\Request\Pet;
use apivalk\apivalk\Documentation\ApivalkRequestDocumentation;
use apivalk\apivalk\Documentation\Property\FloatProperty;
use apivalk\apivalk\Documentation\Property\StringProperty;
use apivalk\apivalk\Http\Request\AbstractApivalkRequest;
class UpdatePetRequest extends AbstractApivalkRequest
{
public static function getDocumentation(): ApivalkRequestDocumentation
{
$doc = new ApivalkRequestDocumentation();
$doc->addPathProperty(new StringProperty('pet_uuid', 'Pet identifier'));
// Patch semantics — every body field is optional
$doc->addBodyProperty((new StringProperty('name', 'Pet name'))->setIsRequired(false));
$doc->addBodyProperty((new FloatProperty('weight', 'Weight in kg'))->setIsRequired(false));
return $doc;
}
}
UpdatePetResponse
<?php
declare(strict_types=1);
namespace App\Http\Response\Pet;
use apivalk\apivalk\Documentation\ApivalkResponseDocumentation;
use apivalk\apivalk\Documentation\Property\StringProperty;
use apivalk\apivalk\Http\Response\AbstractApivalkResponse;
class UpdatePetResponse extends AbstractApivalkResponse
{
/** @var array<string, mixed> */
private $pet;
public function __construct(array $pet)
{
$this->pet = $pet;
}
public static function getStatusCode(): int
{
return self::HTTP_200_OK;
}
public static function getDocumentation(): ApivalkResponseDocumentation
{
$doc = new ApivalkResponseDocumentation();
$doc->addProperty(new StringProperty('pet_uuid', 'Unique identifier'));
$doc->addProperty(new StringProperty('name', 'Pet name'));
return $doc;
}
public function toArray(): array
{
return $this->pet;
}
}
UpdatePetController
<?php
declare(strict_types=1);
namespace App\Http\Controller\Pet;
use apivalk\apivalk\Http\Controller\AbstractApivalkController;
use apivalk\apivalk\Http\Request\ApivalkRequestInterface;
use apivalk\apivalk\Http\Response\AbstractApivalkResponse;
use apivalk\apivalk\Http\Response\ForbiddenApivalkResponse;
use apivalk\apivalk\Http\Response\NotFoundApivalkResponse;
use apivalk\apivalk\Router\Route\Route;
use apivalk\apivalk\Security\RouteAuthorization;
use App\Domain\Pet\PetRepository;
use App\Http\Request\Pet\UpdatePetRequest;
use App\Http\Response\Pet\UpdatePetResponse;
final class UpdatePetController extends AbstractApivalkController
{
/** @var PetRepository */
private $repository;
public function __construct(PetRepository $repository)
{
$this->repository = $repository;
}
public static function getRoute(): Route
{
return Route::patch('/api/v1/pets/{pet_uuid}')
->summary('Update a pet')
->routeAuthorization(new RouteAuthorization('BearerAuth', ['pet'], ['pet:write']));
}
public static function getRequestClass(): string
{
return UpdatePetRequest::class;
}
public static function getResponseClasses(): array
{
return [
UpdatePetResponse::class,
NotFoundApivalkResponse::class,
ForbiddenApivalkResponse::class,
];
}
public function __invoke(ApivalkRequestInterface $request): AbstractApivalkResponse
{
$uuid = $request->path()->pet_uuid;
if (!$this->repository->exists($uuid)) {
return new NotFoundApivalkResponse();
}
$patch = [];
if ($request->body()->has('name')) {
$patch['name'] = $request->body()->name;
}
if ($request->body()->has('weight')) {
$patch['weight'] = $request->body()->weight;
}
$pet = $this->repository->update($uuid, $patch);
return new UpdatePetResponse($pet);
}
}
Delete
DeletePetRequest
<?php
declare(strict_types=1);
namespace App\Http\Request\Pet;
use apivalk\apivalk\Documentation\ApivalkRequestDocumentation;
use apivalk\apivalk\Documentation\Property\StringProperty;
use apivalk\apivalk\Http\Request\AbstractApivalkRequest;
class DeletePetRequest extends AbstractApivalkRequest
{
public static function getDocumentation(): ApivalkRequestDocumentation
{
$doc = new ApivalkRequestDocumentation();
$doc->addPathProperty(new StringProperty('pet_uuid', 'Pet identifier'));
return $doc;
}
}
DeletePetResponse
For idempotent deletes you can return the built-in DeletedApivalkResponse (204) instead of writing your own. If you want to emit a confirmation payload, write one like the others:
<?php
declare(strict_types=1);
namespace App\Http\Response\Pet;
use apivalk\apivalk\Documentation\ApivalkResponseDocumentation;
use apivalk\apivalk\Documentation\Property\StringProperty;
use apivalk\apivalk\Http\Response\AbstractApivalkResponse;
class DeletePetResponse extends AbstractApivalkResponse
{
/** @var string */
private $uuid;
public function __construct(string $uuid)
{
$this->uuid = $uuid;
}
public static function getStatusCode(): int
{
return self::HTTP_200_OK;
}
public static function getDocumentation(): ApivalkResponseDocumentation
{
$doc = new ApivalkResponseDocumentation();
$doc->addProperty(new StringProperty('pet_uuid', 'Deleted identifier'));
return $doc;
}
public function toArray(): array
{
return ['pet_uuid' => $this->uuid];
}
}
DeletePetController
<?php
declare(strict_types=1);
namespace App\Http\Controller\Pet;
use apivalk\apivalk\Http\Controller\AbstractApivalkController;
use apivalk\apivalk\Http\Request\ApivalkRequestInterface;
use apivalk\apivalk\Http\Response\AbstractApivalkResponse;
use apivalk\apivalk\Http\Response\ForbiddenApivalkResponse;
use apivalk\apivalk\Http\Response\NotFoundApivalkResponse;
use apivalk\apivalk\Router\Route\Route;
use apivalk\apivalk\Security\RouteAuthorization;
use App\Domain\Pet\PetRepository;
use App\Http\Request\Pet\DeletePetRequest;
use App\Http\Response\Pet\DeletePetResponse;
final class DeletePetController extends AbstractApivalkController
{
/** @var PetRepository */
private $repository;
public function __construct(PetRepository $repository)
{
$this->repository = $repository;
}
public static function getRoute(): Route
{
return Route::delete('/api/v1/pets/{pet_uuid}')
->summary('Delete a pet')
->routeAuthorization(new RouteAuthorization('BearerAuth', ['pet'], ['pet:delete']));
}
public static function getRequestClass(): string
{
return DeletePetRequest::class;
}
public static function getResponseClasses(): array
{
return [
DeletePetResponse::class,
NotFoundApivalkResponse::class,
ForbiddenApivalkResponse::class,
];
}
public function __invoke(ApivalkRequestInterface $request): AbstractApivalkResponse
{
$uuid = $request->path()->pet_uuid;
if (!$this->repository->exists($uuid)) {
return new NotFoundApivalkResponse();
}
$this->repository->delete($uuid);
return new DeletePetResponse($uuid);
}
}
When to use this over a resource
Use the manual variant when:- Payloads differ between endpoints — e.g.
CreateacceptsspeciesbutUpdatedoesn’t, or the view response returns an expanded shape with joined data. - Responses need custom envelopes —
{"data": ...}is fine for resources, but a legacy API might expect{"pet": ...}with metadata at the root. - You want complete control over validation — per-endpoint
getDocumentation()gives you surgical control over required/optional fields, descriptions, and examples.