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.0"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 class PetRequest implements ModelRequestInterface
{
public string $name;
public ?string $tag = null;
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 class PetResponse implements ModelResponseInterface
{
public string $id;
public string $createdAt;
public ?string $updatedAt = null;
public string $name;
public ?string $tag = null;
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 class PetCollectionRequest implements CollectionRequestInterface
{
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 class PetCollectionFilters implements CollectionFiltersInterface
{
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 class PetCollectionSort implements CollectionSortInterface
{
public ?string $name = null;
public function jsonSerialize(): array
{
return ['name' => $this->name];
}
}Extend AbstractCollectionResponse 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\AbstractCollectionResponse;
final class PetCollectionResponse extends AbstractCollectionResponse
{
public PetCollectionFilters $filters;
public PetCollectionSort $sort;
public array $items;
protected function getFilters(): PetCollectionFilters { return $this->filters; }
protected function getSort(): PetCollectionSort { return $this->sort; }
}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, PetCollectionRequest, PetCollectionResponse, PetCollectionSort};
use App\Pet\Dto\Model\{PetRequest, PetResponse};
use Chubbyphp\Api\Collection\CollectionInterface;
use Chubbyphp\Api\Parsing\ParsingInterface;
use Chubbyphp\Framework\Router\UrlGeneratorInterface;
use Chubbyphp\Parsing\ParserInterface;
use Chubbyphp\Parsing\Schema\ObjectSchemaInterface;
use Psr\Http\Message\ServerRequestInterface;
final class PetParsing implements ParsingInterface
{
public function __construct(
private readonly ParserInterface $parser,
private readonly UrlGeneratorInterface $urlGenerator,
) {}
public function getCollectionRequestSchema(ServerRequestInterface $request): ObjectSchemaInterface
{
$p = $this->parser;
return $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)->strict()->default([]),
'sort' => $p->object([
'name' => $p->union([$p->literal('asc'), $p->literal('desc')])->nullable()->default(null),
], PetCollectionSort::class)->strict()->default([]),
], PetCollectionRequest::class)->strict();
}
public function getCollectionResponseSchema(ServerRequestInterface $request): ObjectSchemaInterface
{
$p = $this->parser;
return $p->object([
'offset' => $p->int(),
'limit' => $p->int(),
'filters' => $p->object(['name' => $p->string()->nullable()], PetCollectionFilters::class)->strict(),
'sort' => $p->object([
'name' => $p->union([$p->literal('asc'), $p->literal('desc')])->nullable()->default(null),
], PetCollectionSort::class)->strict(),
'items' => $p->array($this->getModelResponseSchema($request)),
'count' => $p->int(),
'_type' => $p->literal('petCollection')->default('petCollection'),
], PetCollectionResponse::class)->strict()->postParse(fn ($r) => $this->addCollectionLinks($r));
}
public function getModelRequestSchema(ServerRequestInterface $request): ObjectSchemaInterface
{
$p = $this->parser;
return $p->object([
'name' => $p->string()->minLength(1),
'tag' => $p->string()->minLength(1)->nullable(),
], PetRequest::class)->strict(['id', 'createdAt', 'updatedAt', '_type', '_links']);
}
public function getModelResponseSchema(ServerRequestInterface $request): ObjectSchemaInterface
{
$p = $this->parser;
return $p->object([
'id' => $p->string(),
'createdAt' => $p->dateTime()->toString(),
'updatedAt' => $p->dateTime()->nullable()->toString(),
'name' => $p->string(),
'tag' => $p->string()->nullable(),
'_type' => $p->literal('pet')->default('pet'),
], PetResponse::class)->strict()->postParse(fn ($r) => $this->addModelLinks($r));
}
private function addCollectionLinks(PetCollectionResponse $response): PetCollectionResponse
{
$queryParams = [
'offset' => $response->offset,
'limit' => $response->limit,
'filters' => $response->filters->jsonSerialize(),
'sort' => $response->sort->jsonSerialize(),
];
$response->_links = [
'list' => $this->link($this->urlGenerator->generatePath('pet_list', [], $queryParams), 'GET'),
'create' => $this->link($this->urlGenerator->generatePath('pet_create'), 'POST'),
];
return $response;
}
private function addModelLinks(PetResponse $response): PetResponse
{
$response->_links = [
'read' => $this->link($this->urlGenerator->generatePath('pet_read', ['id' => $response->id]), 'GET'),
'update' => $this->link($this->urlGenerator->generatePath('pet_update', ['id' => $response->id]), 'PUT'),
'delete' => $this->link($this->urlGenerator->generatePath('pet_delete', ['id' => $response->id]), 'DELETE'),
];
return $response;
}
private function link(string $href, string $method): array
{
return ['href' => $href, 'templated' => false, 'rel' => [], 'attributes' => ['method' => $method]];
}
}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