A set of CRUD middleware and request handlers for building APIs with PSR-15.
- php: ^8.3
- chubbyphp/chubbyphp-decode-encode: ^1.4
- chubbyphp/chubbyphp-http-exception: ^1.3.2
- chubbyphp/chubbyphp-parsing: ^2.2
- psr/container: ^1.1.2|^2.0.2
- psr/http-message: ^1.1|^2.0
- psr/http-server-handler: ^1.0.2
- psr/http-server-middleware: ^1.0.2
- ramsey/uuid: ^4.9.2
Through Composer as chubbyphp/chubbyphp-api.
composer require chubbyphp/chubbyphp-api "^1.1"Implement ModelInterface for your domain models. Models must provide an ID, timestamps, and JSON serialization.
<?php
declare(strict_types=1);
namespace App\Pet\Model;
use Chubbyphp\Api\Model\ModelInterface;
use Ramsey\Uuid\Uuid;
final class Pet implements ModelInterface
{
private string $id;
private \DateTimeInterface $createdAt;
private ?\DateTimeInterface $updatedAt = null;
private ?string $name = null;
private ?string $tag = null;
public function __construct()
{
$this->id = Uuid::uuid4()->toString();
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): string { return $this->id; }
public function getCreatedAt(): \DateTimeInterface { return $this->createdAt; }
public function setUpdatedAt(\DateTimeInterface $updatedAt): void { $this->updatedAt = $updatedAt; }
public function getUpdatedAt(): ?\DateTimeInterface { return $this->updatedAt; }
public function setName(string $name): void { $this->name = $name; }
public function getName(): ?string { return $this->name; }
public function setTag(?string $tag): void { $this->tag = $tag; }
public function getTag(): ?string { return $this->tag; }
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'createdAt' => $this->createdAt,
'updatedAt' => $this->updatedAt,
'name' => $this->name,
'tag' => $this->tag,
];
}
}Extend AbstractCollection for paginated lists of models with filtering and sorting support.
<?php
declare(strict_types=1);
namespace App\Pet\Collection;
use Chubbyphp\Api\Collection\AbstractCollection;
final class PetCollection extends AbstractCollection {}The abstract class provides: offset, limit, filters, sort, count, and items.
Data Transfer Objects for request/response transformations.
Implement ModelRequestInterface to handle create and update operations.
<?php
declare(strict_types=1);
namespace App\Pet\Dto\Model;
use App\Pet\Model\Pet;
use Chubbyphp\Api\Dto\Model\ModelRequestInterface;
use Chubbyphp\Api\Model\ModelInterface;
final readonly class PetRequest implements ModelRequestInterface
{
public function __construct(
public string $name,
public ?string $tag,
) {}
public function createModel(): ModelInterface
{
$model = new Pet();
$model->setName($this->name);
$model->setTag($this->tag);
return $model;
}
public function updateModel(ModelInterface $model): ModelInterface
{
$model->setUpdatedAt(new \DateTimeImmutable());
$model->setName($this->name);
$model->setTag($this->tag);
return $model;
}
}Implement ModelResponseInterface for API responses with HATEOAS links.
<?php
declare(strict_types=1);
namespace App\Pet\Dto\Model;
use Chubbyphp\Api\Dto\Model\ModelResponseInterface;
final readonly class PetResponse implements ModelResponseInterface
{
public function __construct(
public string $id,
public string $createdAt,
public ?string $updatedAt,
public string $name,
public ?string $tag,
public string $_type,
public array $_links = [],
) {}
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'createdAt' => $this->createdAt,
'updatedAt' => $this->updatedAt,
'name' => $this->name,
'tag' => $this->tag,
'_type' => $this->_type,
'_links' => $this->_links,
];
}
}Implement CollectionRequestInterface with filter and sort classes.
<?php
declare(strict_types=1);
namespace App\Pet\Dto\Collection;
use App\Pet\Collection\PetCollection;
use Chubbyphp\Api\Collection\CollectionInterface;
use Chubbyphp\Api\Dto\Collection\CollectionRequestInterface;
final readonly class PetCollectionRequest implements CollectionRequestInterface
{
public function __construct(
public int $offset,
public int $limit,
public PetCollectionFilters $filters,
public PetCollectionSort $sort
) {}
public function createCollection(): CollectionInterface
{
$collection = new PetCollection();
$collection->setOffset($this->offset);
$collection->setLimit($this->limit);
$collection->setFilters((array) $this->filters);
$collection->setSort((array) $this->sort);
return $collection;
}
}<?php
declare(strict_types=1);
namespace App\Pet\Dto\Collection;
use Chubbyphp\Api\Dto\Collection\CollectionFiltersInterface;
final readonly class PetCollectionFilters implements CollectionFiltersInterface
{
public function __construct(public ?string $name = null) {}
public function jsonSerialize(): array
{
return ['name' => $this->name];
}
}<?php
declare(strict_types=1);
namespace App\Pet\Dto\Collection;
use Chubbyphp\Api\Dto\Collection\CollectionSortInterface;
final readonly class PetCollectionSort implements CollectionSortInterface
{
public function __construct(public ?string $name = null) {}
public function jsonSerialize(): array
{
return ['name' => $this->name];
}
}Extend AbstractReadonlyCollectionResponse for paginated API responses.
<?php
declare(strict_types=1);
namespace App\Pet\Dto\Collection;
use App\Pet\Dto\Model\PetResponse;
use Chubbyphp\Api\Dto\Collection\AbstractReadonlyCollectionResponse;
final readonly class PetCollectionResponse extends AbstractCollectionResponse
{
public function __construct(
int $offset,
int $limit,
PetCollectionFilters $filters,
PetCollectionSort $sort,
array $items,
int $count,
string $_type,
array $_links = [],
) {
parent::__construct(
$offset,
$limit,
$filters,
$sort,
$items,
$count,
$_type,
$_links,
);
}
}Implement ParsingInterface to define schemas for request/response transformation using chubbyphp/chubbyphp-parsing.
<?php
declare(strict_types=1);
namespace App\Pet\Parsing;
use App\Pet\Dto\Collection\PetCollectionFilters;
use App\Pet\Dto\Collection\PetCollectionRequest;
use App\Pet\Dto\Collection\PetCollectionResponse;
use App\Pet\Dto\Collection\PetCollectionSort;
use App\Pet\Dto\Model\PetRequest;
use App\Pet\Dto\Model\PetResponse;
use Chubbyphp\Api\Collection\CollectionInterface;
use Chubbyphp\Api\Parsing\ParsingInterface;
use Chubbyphp\Framework\Router\UrlGeneratorInterface;
use Chubbyphp\Parsing\Enum\Uuid;
use Chubbyphp\Parsing\ParserInterface;
use Chubbyphp\Parsing\Schema\ObjectSchemaInterface;
use Psr\Http\Message\ServerRequestInterface;
final class PetParsing implements ParsingInterface
{
private ?ObjectSchemaInterface $collectionRequestSchema = null;
private ?ObjectSchemaInterface $collectionResponseSchema = null;
private ?ObjectSchemaInterface $modelRequestSchema = null;
private ?ObjectSchemaInterface $modelResponseSchema = null;
public function __construct(
private readonly ParserInterface $parser,
private readonly UrlGeneratorInterface $urlGenerator,
) {}
public function getCollectionRequestSchema(ServerRequestInterface $request): ObjectSchemaInterface
{
if (null === $this->collectionRequestSchema) {
$p = $this->parser;
$this->collectionRequestSchema = $p->object([
'offset' => $p->union([$p->string()->toInt(), $p->int()->default(0)]),
'limit' => $p->union([
$p->string()->toInt(),
$p->int()->default(CollectionInterface::LIMIT),
]),
'filters' => $p->object([
'name' => $p->string()->nullable()->default(null),
], PetCollectionFilters::class, true)->strict()->default([]),
'sort' => $p->object([
'name' => $p->union([
$p->const('asc'),
$p->const('desc'),
])->nullable()->default(null),
], PetCollectionSort::class, true)->strict()->default([]),
], PetCollectionRequest::class, true)->strict();
}
return $this->collectionRequestSchema;
}
public function getCollectionResponseSchema(ServerRequestInterface $request): ObjectSchemaInterface
{
if (null === $this->collectionResponseSchema) {
$p = $this->parser;
$this->collectionResponseSchema = $p->object([
'offset' => $p->int(),
'limit' => $p->int(),
'filters' => $p->object([
'name' => $p->string()->nullable(),
], PetCollectionFilters::class, true)->strict(),
'sort' => $p->object([
'name' => $p->union([
$p->const('asc'),
$p->const('desc'),
])->nullable()->default(null),
], PetCollectionSort::class, true)->strict(),
'items' => $p->array($this->getModelResponseSchema($request)),
'count' => $p->int(),
'_type' => $p->const('petCollection')->default('petCollection'),
], PetCollectionResponse::class, true)
->strict()
->postParse(function (PetCollectionResponse $petCollectionResponse) {
$queryParams = [
'offset' => $petCollectionResponse->offset,
'limit' => $petCollectionResponse->limit,
'filters' => $petCollectionResponse->filters->jsonSerialize(),
'sort' => $petCollectionResponse->sort->jsonSerialize(),
];
return new PetCollectionResponse(
$petCollectionResponse->offset,
$petCollectionResponse->limit,
$petCollectionResponse->filters,
$petCollectionResponse->sort,
$petCollectionResponse->items,
$petCollectionResponse->count,
$petCollectionResponse->_type,
[
'list' => [
'href' => $this->urlGenerator->generatePath('pet_list', [], $queryParams),
'templated' => false,
'rel' => [],
'attributes' => ['method' => 'GET'],
],
'create' => [
'href' => $this->urlGenerator->generatePath('pet_create'),
'templated' => false,
'rel' => [],
'attributes' => ['method' => 'POST'],
],
],
);
})
;
}
return $this->collectionResponseSchema;
}
public function getModelRequestSchema(ServerRequestInterface $request): ObjectSchemaInterface
{
if (null === $this->modelRequestSchema) {
$p = $this->parser;
$this->modelRequestSchema = $p->object([
'name' => $p->string()->minLength(1),
'tag' => $p->string()->minLength(1)->nullable(),
], PetRequest::class, true)->strict(['id', 'createdAt', 'updatedAt', '_type', '_links']);
}
return $this->modelRequestSchema;
}
public function getModelResponseSchema(ServerRequestInterface $request): ObjectSchemaInterface
{
if (null === $this->modelResponseSchema) {
$p = $this->parser;
$this->modelResponseSchema = $p->object([
'id' => $p->string()->uuid(Uuid::v7),
'createdAt' => $p->dateTime()->toString(),
'updatedAt' => $p->dateTime()->nullable()->toString(),
'name' => $p->string(),
'tag' => $p->string()->nullable(),
'_type' => $p->const('pet')->default('pet'),
], PetResponse::class, true)->strict()
->postParse(
fn (PetResponse $petResponse) => new PetResponse(
$petResponse->id,
$petResponse->createdAt,
$petResponse->updatedAt,
$petResponse->name,
$petResponse->tag,
$petResponse->_type,
[
'read' => [
'href' => $this->urlGenerator->generatePath('pet_read', ['id' => $petResponse->id]),
'templated' => false,
'rel' => [],
'attributes' => ['method' => 'GET'],
],
'update' => [
'href' => $this->urlGenerator->generatePath('pet_update', ['id' => $petResponse->id]),
'templated' => false,
'rel' => [],
'attributes' => ['method' => 'PUT'],
],
'delete' => [
'href' => $this->urlGenerator->generatePath('pet_delete', ['id' => $petResponse->id]),
'templated' => false,
'rel' => [],
'attributes' => ['method' => 'DELETE'],
],
]
)
)
;
}
return $this->modelResponseSchema;
}
}Implement RepositoryInterface for your persistence layer (Doctrine ORM, ODM, etc.).
<?php
use Chubbyphp\Api\Collection\CollectionInterface;
use Chubbyphp\Api\Model\ModelInterface;
use Chubbyphp\Api\Repository\RepositoryInterface;
interface RepositoryInterface
{
public function resolveCollection(CollectionInterface $collection): void;
public function findById(string $id): ?ModelInterface;
public function persist(ModelInterface $model): void;
public function remove(ModelInterface $model): void;
public function flush(): void;
}The library provides PSR-15 request handlers for CRUD operations:
| Handler | Description |
|---|---|
ListRequestHandler |
List collections with pagination, filtering, and sorting |
CreateRequestHandler |
Create new models (returns 201) |
ReadRequestHandler |
Read single models by ID |
UpdateRequestHandler |
Update existing models |
DeleteRequestHandler |
Delete models (returns 204) |
All handlers use content negotiation via accept and contentType request attributes.
2026 Dominik Zogg