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.