AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
kohlrAPI는 경량 Symfony 라이브러리로, 인증 우선 REST API를 CRUD·검색·필터·정렬·페이징 기능과 함께 제공합니다
kohlrAPI – eine leichtgewichtige, Authorization-first Symfony-Bibliothek zum Erstellen von RESTful-APIs mit generischer CRUD-, Such-, Filter-, Sortier- und Paginierungs-Logik. Entwickelt von Philip Mä
개발 도구PHPGitHub @pmaerksch온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기A library for Symfony projects that are building REST(-like) APIs.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗This is a lightweight Symfony library that helps to build a RESTful API. It was developed initially for: AdCaptain and later reused and improved in other private projects of mine. I finally decided to create a standalone package combining the many useful bits from each project and tried to make them reusable for other projects and other people.
It features a set of abstract base classes for Symfony API backends, providing generic CRUD, search, filter, and sort logic. Filters use the format PrimeVue DataTables send.
Author: Philip Märksch
Version: 2.1.0
License: MIT
getUser() / isGranted() in ApiController)kohlrAPI is not on Packagist. Add the GitHub repository as a Composer VCS repository to your Symfony project:
"repositories": [
{
"type": "vcs",
"url": "https://github.com/pmaerksch/kohlrapi"
}
],
"require": {
"pmaerksch/kohlrapi": "^2.1"
}
Then run:
composer install
All classes live under the pmaerksch\Kohlrapi namespace.
ApiEntityAbstract base entity. Extend this in all your Doctrine entities.
id (internal, never exposed)uuid serializer group)parseJson($json) — wraps a client payload (JSON string or already-decoded array) in a JsonHelper for use in updateFromJson() implementationsJsonHelperUtility for reading required/optional keys out of a client payload, typically used from an entity's updateFromJson() via ApiEntity::parseJson().
class Booking extends ApiEntity
{
public function updateFromJson(mixed $json): static
{
$data = $this->parseJson($json);
$this->name = $data->require('name'); // throws if missing/empty
$this->notes = $data->optional('notes', ''); // falls back to default if absent
return $this;
}
}
| Method | Description |
|---|---|
require($key, $allowEmpty = false) | Returns $data[$key]; throws ApiMissingFieldException if the key is missing, or (unless $allowEmpty) if its value is null, '', or []. Falsy values like "0", 0 and false are accepted. |
optional($key, $default = null) | Returns $data[$key] if present, otherwise $default |
The constructor and format() throw an InvalidArgumentException if the input isn't a JSON string decodable to an array, or isn't already an array.
Because require() throws ApiMissingFieldException, a controller can turn it into a 422 with missingFieldResponse().
ArraySerializableOptional interface for entities that serialize themselves to an array instead of relying on the Symfony serializer + serializer groups.
interface ArraySerializable
{
public function getDataAsArray(): array;
}
When an entity returned through singleResponse() / listResponse() (and therefore through handleFetch / handleSearch / handleCreate / …) implements this, its getDataAsArray() output is used verbatim; any other object is run through the serializer with the given groups. This lets read endpoints emit rich, relation-aware and computed payloads without serializer-group annotations.
class Booking extends ApiEntity implements ArraySerializable
{
public function getDataAsArray(): array
{
return [
'uuid' => $this->uuid,
'customer' => $this->customer->getDataAsArray(), // nested relation
'unpaid' => $this->calcIsUnpaid(), // computed field
// ...
];
}
}
Entities that don't implement it keep using the serializer-group path — the two strategies coexist, chosen per entity. Include 'uuid' in your array yourself (the serializer path adds it automatically; the array path does not).
ErrorDetailAwareOptional interface implemented by your user entity to opt into detailed API error messages.
interface ErrorDetailAware
{
public function showsErrorDetails(): bool;
}
errorResponse() always logs the full message server-side (error level for 5xx, info for client errors) and returns a stable key the client can localise. The human-readable error detail — which may carry internals (exception text, SQL, file paths) — is included in the response only when the authenticated user implements this interface and returns true; otherwise error is null. This keeps sensitive detail out of normal responses while letting privileged users (e.g. developers) see it.
ApiRepositoryAbstract base repository providing generic search, filter, sort, and pagination.
Subclasses must implement:
| Method | Description |
|---|---|
getAlias() | DQL alias for the root entity (e.g. 'u', 'p') |
getEntityFieldMap() | Maps frontend filter/sort keys to DQL expressions. id and uuid are added automatically. |
applyGlobalSearch() | Full-text WHERE clause applied when a term is provided |
Subclasses may override:
| Method | Description |
|---|---|
applyBaseConditions() | Mandatory constraints on every query (e.g. soft-delete, visibility) |
applyCustomSort() | Handle aggregate or JOIN-based sort cases; return true if handled |
getDefaultSort() | Fallback sort field and direction (default: id DESC) |
getMaxLimit() | Per-entity result cap; return null for no cap |
getMaxOffset() | Largest pagination offset (default 100000) |
getMaxTermLength() | Longest global search term in characters (default 255) |
getMaxConstraints() | Most filter constraints per request, across all fields (default 50) |
getMaxInValues() | Most values in one in constraint (default 1000) |
Pagination counts entities, not SQL rows (via Doctrine's Paginator), so applyBaseConditions(), applyGlobalSearch() and applyCustomSort() may join, fetch-join collections, GROUP BY and filter with HAVING; items and count stay correct.
A request exceeding one of the last four limits throws ApiInvalidSearchException, which handleSearch() answers with 400 (api.errors.invalidSearch). Return null from a limit to disable it. The limits also apply to getEntityList() / getRandomEntityList() — catch the exception there if you call them yourself.
{
"fieldKey": {
"operator": "and|or",
"constraints": [{ "value": "...", "matchMode": "contains" }]
}
}
Supported matchMode values: contains, notContains, startsWith, endsWith, equals, notEquals, lt, lte, gt, gte, in
in expects a non-empty array of scalars; every other mode expects a single scalar. Constraints with a value of the wrong shape are ignored. % and _ in the LIKE-based modes are matched literally, not as wildcards.
{ "field": "fieldKey", "order": "ASC|DESC" }
ApiControllerAbstract base controller. Extend this in all your API controllers.
Its constructor is autowired with EntityManagerInterface, SerializerInterface, KernelInterface, LoggerInterface, ValidatorInterface and RequestStack — so a plain subclass needs no constructor. If you do declare one (e.g. to inject a permission service), forward all of these to parent::__construct(...).
Response helpers:
| Method | Returns |
|---|---|
errorResponse($message, $status, $e, $key, $details) | { error, key, ...details } — the error detail is only exposed to users implementing ErrorDetailAware; otherwise null. $details are structured, client-safe fields sent to everyone |
successResponse($data) | 200 { message, ...data } |
createdResponse($data) | 201 { message, ...data } |
noContentResponse() | 204 |
accessDeniedResponse() | 401 for anonymous users, 403 for authenticated ones |
notFoundResponse() | 404 { error, key: 'api.errors.recordNotFound' } — for an entity that is missing or that the user may not see, so both look the same |
validationErrorResponse($violations) | 422 { error, key, violations: { field: message } } |
tooManyItemsResponse($maxItems) | 413 { …, maxItems } — returned by the bulk handlers when a request exceeds $maxItems |
listResponse($items, $groups, $count, $maxCount) | 200 { message, items, count?, maxCount? } — count is the number of items on this page, maxCount the total matching the search/filters (ignoring only the pagination limit), so the frontend paginator sizes itself to the filtered result set. Items implementing ArraySerializable are serialized via getDataAsArray(), others via the serializer + $groups |
singleResponse($data, $groups) | 200 { message, data } — same ArraySerializable-aware serialization as listResponse |
Input handling:
| Method | Description |
|---|---|
deserializeInput($class, $request) | Deserializes request JSON into a DTO. On bad JSON it returns a 400 JsonResponse instead of the DTO — check instanceof JsonResponse before using the result |
validateInput($input) | Validates a DTO via Symfony Validator; returns 422 with per-field violations or null if valid |
collectViolations($input, $prefix) | Validates an object and returns its violations as a { field: message } array (empty if valid), each key prefixed with $prefix |
requireField($data, $key, $filter) | Extracts a field from decoded JSON; throws ApiMissingFieldException if absent or invalid |
missingFieldResponse($e) | Converts an ApiMissingFieldException into a 422 { …, field } response |
Generic CRUD handlers:
| Method | Description |
|---|---|
handleFetch($classname, $uuid, $authLevel, $groups) | Fetch a single entity by UUID with auth check |
handleCreate($classname, $request, $authLevel) | Deserialize, validate + persist a new entity. Returns 400 if the body is malformed (invalid JSON, wrong value types) and 422 with violations if the entity is invalid. |
handleUpdate($classname, $request, $authLevel, $uuid = null) | Deserialize, validate + update an existing entity by UUID — $uuid, or the route parameter {uuid} when omitted. Returns 400 if the body is malformed and 422 with violations if the result is invalid; the rejected changes are discarded. |
handleBulk($classname, $request, $authLevel, $maxItems = 1000) | Create or update multiple entities, all-or-nothing; items with a uuid are updated, items without are created. Returns { uuids: string[] } in input order. Authorization runs for the request (bulk) and again per item (update with the entity as subject, or create), then on each resulting entity (update.after / create.after). Every item is validated; see Bulk endpoints. |
handleBulkDelete($classname, $request, $authLevel, $maxItems = 1000) | Delete multiple entities by UUID, all-or-nothing. Expects a JSON array of UUID strings. Returns 204. Authorization runs for the request (bulkDelete, no subject) and again per entity (delete with the entity as subject). |
handleSearch($classname, $request, $authLevel, $groups) | Parses search params from the request body, then runs a paginated search; returns items + count + maxCount. Returns 400 on invalid JSON (api.errors.invalidJson) or a rejected search (api.errors.invalidSearch, see ApiRepository). |
getEntity($classname, $uuid, $authLevel) | Fetch a single entity object (no serialization) |
Utility:
| Method | Description |
|---|---|
resolveByUuid($classname, $uuid) | Resolves a UUID string to a Doctrine entity instance, or null if not found. Useful in custom create/update methods when relations are sent as UUIDs from the frontend. |
handleCreate, handleUpdate and handleBulk deserialize the client payload straight into the entity. Without a restriction, every property with a setter (or public) could be set by the client — including fields like roles, owner or password. So every controller using these handlers must override getDenormalizationContext() and return serializer groups or attributes; otherwise the handler throws a LogicException. uuid is always ignored.
protected function getDenormalizationContext(string $classname, string $operation): array
{
// $operation is 'create' or 'update'
return ['groups' => ['write']];
// or: return [AbstractNormalizer::ATTRIBUTES => ['name', 'notes']];
}
Empty groups and the '*' group don't count as a restriction (the serializer reads both as "every field"). To deliberately allow every field (the behaviour before 2.0), return [self::WRITE_ALL_FIELDS => true].
Every handle* and getEntity* helper takes an $authLevel and routes the check through two methods:
| Method | Role |
|---|---|
can($action, $subject?, $subjectClass?, $operation?) | Call this. Assembles an AuthorizationContext (auto-filling the current user and request) and asks isAuthorized(). Returns bool. |
isAuthorized(AuthorizationContext $context) | Override this. The decision itself. Default delegates to Symfony's voters. |
By default $action is a Symfony role/attribute (e.g. ROLE_ADMIN, IS_AUTHENTICATED_FULLY) and the CRUD handlers default to ROLE_ADMIN — i.e. fail-closed; pass a weaker level explicitly to open an endpoint up:
return $this->handleSearch(Booking::class, $request, 'IS_AUTHENTICATED_FULLY');
In your own controller actions, run ad-hoc checks through can() so they go through the same gate:
if (!$this->can('ROLE_ADMIN')) { /* 403 */ }
Plugging in a non-Symfony permission system: override isAuthorized() in your own base controller. Because it receives the full AuthorizationContext, the override can decide on whatever it wants and ignore Symfony roles entirely:
abstract class BaseController extends ApiController
{
public function __construct(/* …parent deps…, */ private MyPermissions $perms) { parent::__construct(/* … */); }
protected function isAuthorized(AuthorizationContext $ctx): bool
{
return $this->perms->decide($ctx); // your rules, your inputs
}
}
Routing every check through can() keeps authorization to a single chokepoint that your isAuthorized() override fully controls.
Checking the result of a write: checking an update only against the entity before the payload is applied would let a payload e.g. move the entity to an owner or tenant the user has no rights on. So every create/update (including bulk items) is checked again after the payload has been applied and before anything is written — with operation create.after / update.after and the resulting entity as $subject. A denial discards the changes and returns 401/403.
Voters and isAuthorized() overrides must handle (or deliberately allow) these two operations. To turn the second check off for a controller:
protected function reauthorizesWrites(): bool
{
return false;
}
AuthorizationContextImmutable bundle passed to isAuthorized(). Built by can() — you never construct it yourself; overrides only read the fields they need. New fields may be added in future versions without breaking your override.
| Field | Type | Description |
|---|---|---|
$action | string | The requested permission token (a role for the default impl) |
$subjectClass | ?string | FQCN of the entity the action concerns, when known (set even with no instance, e.g. create/search) |
$subject | ?object | The loaded entity the action concerns (fetch/update); null otherwise |
$operation | ?string | 'search' | 'fetch' | 'create' | 'update' | 'bulk' | 'bulkDelete' | 'delete', plus 'create.after' | 'update.after' for the second check on writes (see reauthorizesWrites()) |
$user | ?object | The authenticated user, or null |
$request | ?Request | The current HTTP request, when available |
Note: for
fetch/updatethe entity is loaded before the auth check so it is available as$subject(enabling per-resource decisions). The default Symfony implementation passes$subjecton toisGranted(), so your voters receive the entity.
Existence is never revealed. An entity the user may not access is answered exactly like a missing one, so a client can't probe which uuids exist. Both cases fall back to the class-level check without a subject: if that denies, the response is 401/403; if it allows, 404. In the bulk handlers (whose request-level check has already passed) a forbidden item is always 404. getEntity() returns null for a forbidden entity when the class-level check allows.
#[Route('/bulk', name: 'items_bulk', methods: ['POST'])] // create + update
#[Route('/bulk', name: 'items_bulk_delete', methods: ['DELETE'])] // delete
Bulk save request body (array of payloads — uuid present = update, absent = create):
[
{ "uuid": "existing-uuid", "name": "Updated" },
{ "name": "New item" }
]
Bulk delete request body (array of UUIDs):
["uuid-1", "uuid-2", "uuid-3"]
Both bulk requests are all-or-nothing: if any item is not found or not permitted (both 404 { …, uuid }, see below) or invalid, nothing is written.
Validation. Each item is validated against the entity's Symfony Validator constraints. Violations for all items are returned together, keyed <index>.<field>:
{ "error": "Validation failed", "key": "api.errors.validationFailed", "violations": { "1.name": "This value should not be blank." } }
Item limit. Both handlers accept at most $maxItems items per request (default 1000) and return 413 (key: api.errors.tooManyItems) above that. Choose a limit per endpoint, or pass null to disable it:
return $this->handleBulk(Booking::class, $request, 'ROLE_ADMIN', maxItems: 200);
return $this->handleBulk(LogEntry::class, $request, 'ROLE_ADMIN', maxItems: null); // no limit
Note: UUID-to-entity resolution for relation fields (e.g.
"owner": "some-uuid") requires aUuidEntityDenormalizerregistered in the consuming application. This is intentionally kept out of the package since it depends on the app's Doctrine setup.
ApiSearchParamsImmutable value object representing a search request. Constructed internally by handleSearch; use directly only when calling getEntityList or getRandomEntityList.
// Parsed from a JSON request body (used internally by handleSearch)
$params = ApiSearchParams::fromRequest($request);
// From an array
$params = ApiSearchParams::fromArray($data);
// For internal/programmatic use
$params = ApiSearchParams::fromInternal(limit: 10, filters: ['status' => ...]);
| Property | Type | Default | Description |
|---|---|---|---|
$term | string | '' | Global search term |
$filters | array | [] | Per-field filter constraints |
$sort | ?array | null | Sort field and direction |
$offset | int | 0 | Pagination offset |
$limit | int | 25 | Page size (capped at 500) |
ApiMissingFieldExceptionThrown by requireField() and JsonHelper::require() when a required field is absent or fails the provided filter callable. Carries the field name and produces the message "Missing or invalid field: {field}".
composer install
composer test # PHPUnit: Doctrine ORM on in-memory SQLite + the real Symfony serializer/validator
composer analyse # PHPStan, level 8
CI runs both on PHP 8.4 and 8.5, plus once with the lowest allowed dependency versions.
See CHANGELOG.md, including the upgrade guide from 1.x.
MIT — see LICENSE.
getEntityList($repository, $searchParams, $authLevel) | Fetch a list of entity objects (no serialization) |
getRandomEntityList($repository, $searchParams, $authLevel) | Fetch a random list of entity objects (no serialization). Portable across databases: the matching ids are streamed and sampled in PHP (reservoir sampling, uniform), so no custom RAND() DQL function is needed and PHP holds only limit ids at a time. The database still sends every matching id, so keep random searches filtered on very large tables. |