phloxcz / rest-api
REST server for Nette: API controllers as DI services, with attribute-based routing, authorization and typed parameters.
Requires
- php: >=8.4
- ext-mbstring: *
- nette/di: ^3.1
- nette/http: ^3.2
- nette/schema: ^1.3
- nette/utils: ^4.0
- phloxcz/api-core: ^0.2@dev
Requires (Dev)
- nette/application: ^3.2
- nette/bootstrap: ^3.2
- nette/tester: ^2.5
- phloxcz/rpc-api: ^0.2@dev
- phpstan/phpstan: ^2.0
- tracy/tracy: ^2.10
Suggests
- nette/application: To return Nette\Application\Response objects from controllers
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-07 18:16:08 UTC
README
English | Česky
REST server for Nette.
API controllers are ordinary DI services. Their routes, authorization and parameters are described by PHP attributes and native types, and the route table is compiled into the DI container, so nothing is reflected or cached at runtime to find a route.
⚠️ This package is under active development. The API is not yet stable.
Requirements
PHP 8.4+ and phloxcz/api-core, which documents controllers, authorization and parameter types.
Optional: nette/application for returning Nette\Application\Response objects.
Installation
composer require phloxcz/rest-api
extensions: restApi: Phlox\RestApi\DI\Extension restApi: endpoints: api: # served under /api/ namespace: App\ApiModule # string or list; includes sub-namespaces authorize: true # default for methods without an attribute accessControl: allowedOrigins: ['https://app.example.com'] allowCredentials: true services: - App\ApiModule\UserController
Usage
namespace App\ApiModule; use Phlox\ApiCore\ApiController; use Phlox\ApiCore\Attributes\Authorize; use Phlox\RestApi\Attributes\HttpDelete; use Phlox\RestApi\Attributes\HttpGet; use Phlox\RestApi\Attributes\HttpPost; use Phlox\RestApi\Attributes\Route; #[Route('users')] class UserController extends ApiController { #[HttpGet] #[Route('')] public function list(int $page = 1): array { /* GET /api/users?page=2 */ } #[HttpGet] #[Route('{id:int:min(1)}')] public function detail(int $id): array { /* GET /api/users/5 */ } #[HttpPost] #[Route('')] public function create(string $name, int $age = 0): array { /* POST /api/users, {"name": "Ann"} */ } #[HttpDelete] #[Route('{id:int}')] #[Authorize('admin')] public function remove(int $id): void { /* DELETE /api/users/5 */ } }
A public method is an endpoint when it has an HTTP verb attribute: #[HttpGet], #[HttpPost], #[HttpPut], #[HttpPatch], #[HttpDelete], #[HttpHead] or #[HttpOptions]. One method can have several. A method with #[Route] but no verb fails when the container compiles.
Routes
Without #[Route], the path comes from the name in kebab-case: UserProfileController::showDetail() is user-profile/show-detail. A controller's path and a method's path are joined with a slash. #[Route('')] leaves that part out. Paths are case-sensitive and a trailing slash is ignored. The endpoint's root (/api itself) is not a route; it stays with the application, or with a JSON-RPC endpoint of the same name.
| Parameter | Matches |
|---|---|
{name} |
anything up to the next slash |
{?name} |
the same, optional, including its slash: a/{?b}/c matches a/c and a/x/c |
{id:int}, {id:long} |
an integer |
{n:float}, {n:decimal}, {n:double} |
a number |
{flag:bool} |
true, false, 0, 1 |
{a:alpha} |
letters |
{g:guid}, {g:intOrGuid} |
a GUID, or an integer or a GUID |
{path:all} |
the rest of the path, including slashes |
{code:regex} |
the expression from #[Route('{code:regex}', ['code' => '[A-Z]{3}'])] |
Constraints follow the type: min(1), max(9), range(1,9), length(3), length(1,3), minlength(1), maxlength(3), for example {id:int:min(1):max(9)}.
A value that doesn't fit means the route doesn't match, and the next one is tried. Types and constraints only select a route; the value is converted according to the type of the method's parameter. A missing optional parameter gets that parameter's default value. Values are URL-decoded.
Text outside {...} is literal, and an expression has to go into a regex parameter: instead of (users|people)/{id}, write #[Route('{kind:regex}/{id}', ['kind' => 'users|people'])]. Expressions are case-sensitive unless they start with (?i).
A mistake in a route fails when the container compiles: {id:imt}, two methods with the same verb and path, or any of the characters ( ) [ ] | + * ? ^ $ \ in literal text.
Order
The order of controllers and methods doesn't matter. Literal routes are tried first. The others are compared segment by segment from the left: literal text, then a constrained parameter, a plain one and a catch-all. So for GET users/me, users/{id:int} and users/{slug}, the path users/me goes to the first one, users/5 to the second and users/john to the third.
Parameters and bodies
Parameters are filled from the path, then the query, then a JSON body of POST, PUT and PATCH, and the earlier source wins: a client can't override {id} from the path with a query parameter or a body field. Conversions are described in phloxcz/api-core.
A missing Content-Type counts as application/json, but a body which isn't JSON then simply doesn't fill any parameters, unless the method has #[FromBody]. application/*+json is JSON too. A body of any other type isn't read at all unless the method asks for it, so a controller can stream a large upload from php://input itself. $this->jsonData holds the decoded body; with jsonForceArray: false it's made of objects instead of arrays.
#[FromBody] gives one parameter the whole body. JSON is decoded (into an array when the parameter accepts one, otherwise into an object) and anything else stays a string:
#[HttpPost] public function import(#[FromBody] array $rows): void { /* ... */ } #[HttpPut] #[Route('{id:int}/photo')] #[Consumes('image/*')] #[Consumes('application/pdf')] public function upload(int $id, #[FromBody] string $content): void { /* ... */ }
#[Consumes] lists accepted content types. A body of another type gets 415. A method with #[FromBody] and no #[Consumes] accepts application/json; a method with neither accepts anything.
Without #[FromBody], #[Consumes] checks only the type, and the body stays unread for the method to stream. The documentation then describes the body as a file:
#[HttpPost] #[Route('{id:int}/attachment')] #[Consumes('application/octet-stream')] public function attach(int $id): array { $bytes = stream_copy_to_stream(fopen('php://input', 'rb'), fopen($this->storage->path($id), 'wb')); // ... }
Headers and forms
#[FromHeader] fills a parameter with a header, and #[FromForm] with a field of a submitted form, multipart/form-data or application/x-www-form-urlencoded. A file comes as Nette\Http\FileUpload:
/** @param list<FileUpload> $attachments */ #[HttpPost] #[Route('{id:int}/document')] public function document( int $id, #[FromForm] FileUpload $file, #[FromForm] ?string $note = null, #[FromForm('attachment')] array $attachments = [], #[FromHeader('X-Request-Id')] ?string $requestId = null, ): array { /* ... */ }
- Names: the parameter's own, or the one given. A header without one is made from the parameter, so
$ifMatchreadsIf-Match, in any case. - Values are converted as query parameters are. One missing is as if not sent: the default, null, or
422withis required. - Files: an empty file input is as if not sent. A file too large gets
422withis too large., a broken uploadcould not be uploaded. - Only from there: a query parameter or a JSON field of the same name never fills them.
- Content type: a method with
#[FromForm]and no#[Consumes]accepts both form types, and anything else gets415. It can't have#[FromBody]too.
A form works with POST, PUT and PATCH. PHP reads one of POST itself, and the others are read by request_parse_body(), only for a method taking a form.
A DTO can be the whole body or one of the parameters. See DTOs.
#[HttpPost] #[Route('')] public function create(#[FromBody] CreateUser $input): UserOutput { /* ... */ } /** @param list<CreateUser> $users */ #[HttpPost] #[Route('import')] public function import(#[FromBody] array $users): void { /* ... */ }
Problems in a body are reported without the parameter's name: name, addresses.1.city.
Saving data
What comes in is a DTO, and what goes out is what a query asks for. An entity doesn't get built from a request, so a client can't set anything the DTO doesn't declare, such as id. #[FromBody] App\Entity\Type $type gets 422. These examples use phloxcz/entity-database and phloxcz/api-query, and phlox-api-sample has them running.
#[Dto] final readonly class SaveType { public function __construct( #[Length(min: 1, max: 50)] public string $ident, #[Length(max: 100)] public ?string $name = null, ) { } }
POST: create
#[HttpPost] #[Route('')] #[Response(201)] public function create(#[FromBody] SaveType $type, #[QueryOf(TypeResource::class, single: true)] ?Query $query = null): ?array { try { $created = $this->db->table('type')->insert([ 'ident' => $type->ident, // keys are names of columns 'name' => $type->name, ]); } catch (UniqueConstraintViolationException) { throw new ApiException(409, "Type '{$type->ident}' already exists."); } $this->response->setCode(201); return $this->processor->process($created, $query); }
To not repeat the names of columns, let an entity translate them:
$new = new Type(); $new->ident = $type->ident; $new->name = $type->name; $created = $this->db->table('type')->insert($new->toModifiedArrayDb());
insert() of one row gives its entity, also on Firebird. On a table without a primary key it gives the number of rows, and on SQLite, the key has to be AUTOINCREMENT for it, or be given.
PUT: replace
What isn't sent gets its default from the DTO, so leaving out name above sets it to null, and leaving out ident is 422.
#[HttpPut] #[Route('{id:int}')] public function replace(int $id, #[FromBody] SaveType $type, #[QueryOf(TypeResource::class, single: true)] ?Query $query = null): ?array { $entity = $this->findType($id); $entity->ident = $type->ident; $entity->name = $type->name; $entity->saveModified(); // writes only the columns that changed return $this->processor->process($entity, $query); } private function findType(int $id): Type { return $this->db->table('type')->get($id) ?? throw new ApiException(404, 'Type not found.'); }
A private method isn't an endpoint. To keep a record within what the client may reach, limit the selection first: $this->db->table('tasks')->where('project_id', $projectId)->get($id).
PATCH: change what was sent
The same DTO, through a Patch, which tells a field left out from one sent as null. See Changing only what was sent.
#[HttpPatch] #[Route('{id:int}')] public function change(int $id, #[FromBody, PatchOf(SaveType::class)] Patch $changes, #[QueryOf(TypeResource::class, single: true)] ?Query $query = null): ?array { $entity = $changes->applyTo($this->findType($id)); $entity->saveModified(); return $this->processor->process($entity, $query); }
applyTo() needs the fields to have the names of the properties of the entity. Where they differ, or for what needs care, ask one by one: if ($changes->has('name')) { ... }.
Collections
A DTO holds a list by a docblock, see Collections. To save all of it or none of it, use a transaction:
#[HttpPost] #[Route('batch')] #[Response(201, new Shape(['ids' => new ListOf('int')]))] public function batch(#[FromBody] ImportTypes $import): array { $ids = $this->db->transaction(static function (Explorer $db) use ($import): array { $ids = []; foreach ($import->types as $type) { $ids[] = $db->table('type')->insert(['ident' => $type->ident, 'name' => $type->name])->id; } return $ids; }); $this->response->setCode(201); return ['ids' => $ids]; }
The answer
| What happens | Status |
|---|---|
Created, by setCode(201) |
201 |
| Saved, with a record to return | 200 |
| Saved, with nothing returned | 204 |
| A value breaking the rules of the DTO, with a path to each | 422 |
A record not found, by your ApiException |
404 |
A unique value already taken, by your catch |
409 |
| Any other failure of the database | 500, and it's logged |
A Query in a method with #[FromBody] comes from the URL, by the name of its parameter: ?query[select]=id,name. A plain ?select= isn't a parameter of the method, so it's ignored.
Responses
| The controller | The client gets |
|---|---|
| returns a value | JSON, 200 |
returns nothing or null |
no body, 204 |
sets a code with $this->response->setCode(201) |
that code, with or without a value |
returns Nette\Application\Response |
whatever it sends |
calls $this->terminate() |
nothing added |
writes the response itself with readfile() or print and returns nothing |
what it wrote |
Errors have the form {"error": {"code": 404, "message": "..."}}, with data of an ApiException merged next to code and message. The code is the HTTP status, unless the application gave its own errorCode:
| Situation | HTTP status and code |
|---|---|
| unknown path | 404 |
wrong verb, with an Allow header |
405 |
| malformed JSON | 400 |
| unsupported content type | 415 |
invalid parameters, with what's wrong in fields |
422 |
| not logged in | 401 |
| missing role | 403 |
| unexpected exception (logged, details hidden) | 500 |
ApiException |
its status; code is its errorCode, or the status |
Codes of JSON-RPC, which the transports share, never reach a client of REST. See Errors.
The content type and JSON syntax are checked before authorization, so these two errors can reach an anonymous client. Parameters are validated after it.
OpenAPI
Every endpoint gets an OpenAPI 3.1 document, generated when the container compiles from the same routes the server runs on. In debug mode it's published at /api/openapi.json and /api/openapi.yaml, and Swagger UI at /api/openapi, to read it and try the endpoints in a browser. What it's made of is described in phloxcz/api-core.
restApi: endpoints: api: namespace: App\ApiModule documentation: expose: true # by default only in debug mode path: openapi # gives /api/openapi.json, /api/openapi.yaml and Swagger UI at /api/openapi ui: true # Swagger UI; false leaves it out # uiAssets: https://intranet.example.com/swagger-ui # where its scripts are; by default jsDelivr info: {title: Orders API, version: 2.1.0} securitySchemes: bearerAuth: {type: http, scheme: bearer}
On top of that, REST documents these on its own:
- Paths: a route with an optional parameter becomes a path for every combination, because OpenAPI has no optional path parameters.
- Path parameters: types and constraints of a route, so
{id:int:min(1)}is an integer withminimum: 1and{id:guid}hasformat: uuid. - Other parameters: in the query, except for
POST,PUTandPATCHwithout#[FromBody], where they're properties of a JSON body. - Responses: the native return type or
#[QueryOf],204for nothing, and for?Fooboth. - Errors:
400,401,403,404,415and422wherever they can happen. - Security: with
securitySchemes, a method requiring login lists all schemes as alternatives, and a public one hassecurity: []. Roles stay inx-rolesand the description, because a security requirement can't say "admin or editor".
#[Response] adds what the method can't tell, and a successful response declared there replaces the one read from the method:
use Phlox\ApiCore\Schema\{Binary, Paged, Shape}; #[HttpGet] #[Response(200, new Shape(['value' => new Paged(User::class)]))] public function list(?Query $query = null): array { /* ... */ } #[HttpPost] #[Response(201)] // the return type, with a different code public function create(#[FromBody] CreateUser $input): UserOutput { /* ... */ } #[HttpGet] #[Response(200, new Binary, 'application/pdf', 'The invoice')] #[Response(404, description: 'No such invoice')] // a code from 400 gets the schema of errors public function download(int $id): FileResponse { /* ... */ }
The content of #[Response] is a class, a scalar, or an object such as new ListOf(...), new Shape([...]) or new Paged(...), see Describing a response. A docblock @return isn't read.
While the document is published, a route of your own at the same path fails when the container compiles. Change path if that happens.
Testing that responses match it
OpenApiChecker checks a response against the document, so a test notices the documentation drifting from what the API returns:
use Phlox\RestApi\Testing\OpenApiChecker; $checker = OpenApiChecker::of($container->getByType(OpenApi::class), 'api/v1', '/api/v1'); $errors = $checker->check('GET', '/api/v1/users?page=2', $status, $contentType, $body); Assert::same([], $errors);
It reports a status the operation doesn't document, JSON where none is documented, and every value not matching the schema, with where it is, such as GET /api/v1/users 200: $[0].id: "3" is not integer. A response the document doesn't cover, such as of another API, is no error. Run every response of your HTTP tests through it.
CORS
Without allowedOrigins, no CORS headers are sent. An origin not on the list gets none either. ['*'] sends a literal *; combining it with allowCredentials: true fails when the container compiles, because browsers refuse it. A client sending an Authorization header needs no credentials here; for cookies, list the origins.
An OPTIONS request gets 204, unless a method declares #[HttpOptions] for that path. If clients send a token, add authorization to allowHeaders.
Configuration
| Key | Default | Description |
|---|---|---|
debug |
%debugMode% |
Adds internal messages to error responses |
dateTimeFormats |
see ParameterBinder |
Accepted formats of date parameters |
endpoints.<name>.namespace |
required | Controllers served by the endpoint |
endpoints.<name>.authorize |
true |
Whether methods without an attribute require login |
endpoints.<name>.jsonForceArray |
true |
Whether $this->jsonData uses arrays rather than objects |
endpoints.<name>.accessControl |
no CORS | allowedOrigins, allowCredentials, allowMethods, allowHeaders, exposeHeaders, maxAge |
endpoints.<name>.documentation |
generated, published in debug mode | see OpenAPI |
An endpoint's name can contain a slash, such as api/v2, and the longest match wins. It can live in one application with phloxcz/rpc-api, even under the same name.
Migrating from abrad450/api-server-nette
The whole way is in MIGRATION.md. What concerns this package:
Namespaces:
| Old | New |
|---|---|
AbraD450\ApiServer\ApiController |
Phlox\ApiCore\ApiController |
AbraD450\ApiServer\Common\Attributes\{Authorize,AllowAnonymous,NonApi} |
Phlox\ApiCore\Attributes\* |
AbraD450\ApiServer\Common\Attributes\FromBody |
Phlox\RestApi\Attributes\FromBody |
AbraD450\ApiServer\REST\Attributes\* |
Phlox\RestApi\Attributes\* |
AbraD450\ApiServer\REST\ApiException |
Phlox\ApiCore\ApiException |
AbraD450\ApiServer\ApiConstructible |
Phlox\ApiCore\ApiConstructible |
Changes that need checking in an existing project:
- Config:
apiServer: rest: <name>becomesrestApi: endpoints: <name>. - Authorization is on by default. Mark public methods with
#[AllowAnonymous], or setauthorize: falseon the endpoint. #[Authorize(['a', 'b'])]now means a or b, so it lets in more users than before. Where both roles are needed, write#[Authorize(['a', 'b'], all: true)]. Attributes on a class and a method still have to hold together.- A verb is required. A method with no verb used to accept any verb. Now it's not an endpoint, and if it has
#[Route], the container doesn't compile. - Inherited methods are no longer exposed. A method from a shared base controller has to be overridden in the concrete controller.
ApiExceptiontakes the HTTP status first:new ApiException(404, 'Not found.'), with a code of your own aserrorCode:. A code in place of the status, as innew ApiException(1001, 'x'), fails with the right call in the message. The same goes for#[Throws].- The
codeof an error is the HTTP status, unless the application gave its own: an unknown path404, a wrong verb405, malformed JSON400, an unsupported content type415, invalid parameters422and an unexpected exception500. They used to be codes of JSON-RPC, such as-32601and-32602. Check clients which readcode. - Status codes changed. Errors that were
500by default are now400,404,405,415or422, see above. A method with no value returns204instead of an empty200. - Routes are stricter. Paths and
regexexpressions are case-sensitive (start an expression with(?i)to change that), a controller's path must end at a segment boundary, and an unknown token such as{id:imt}fails instead of being ignored. A regular expression in literal text, such as(users|people)/{id}, fails too and has to move into aregexparameter. #[Consumes]is enforced, where it used to serve only for documentation and JSON decoding. Without#[FromBody], it no longer reads the body.- Path parameters are URL-decoded.
- CORS:
allowedOrigins: ['*']withallowCredentials: truemust become a list of origins. - The bootstrap runs before the API. Code after
createContainer()now also runs for API requests, including anything like a redirect or a maintenance mode.index.phphas to callApplication::run(). - Dates and times: a time zone alone, such as
xorutc, is now refused instead of meaning the current time. - Documentation: the
openapiconfig withcontentandoutputPathis gone. MoveinfoandsecuritySchemestodocumentation, anything else todocumentation.extra, and write files withOpenApi::toYaml()if you need them.#[Response]moves toPhlox\RestApi\Attributes\Response, and its content is an object such asnew Shape([...])ornew Paged(Foo::class)instead of the array notation,@pager,@binaryand@error.#[Parameter]is gone, because the type of the parameter, a docblock or#[Schema]says the same. What's gone fails when the container compiles, saying what to write, see Documentation in the migration guide. - The query language moved to
phloxcz/api-query, including$this->processor.