Changelog - Version 7.0
Status: in development. This document tracks changes landing on the
7.0branch. Nothing here is released yet, and the contents may still change.
Overview
Version 7.0 renames the core request-handling classes to say what they are, reworks the
HttpRequest accessor API into an explicit and type-safe set of methods, corrects several
HTTP status codes and content negotiation bugs, and adds PSR-11 dependency injection for
route controllers.
New Features
Dependency Injection for controllers
Controllers can now declare their dependencies in the constructor instead of fetching them
themselves. Pass any PSR-11 container to the new Server::withContainer() and route
controllers are resolved from it rather than built with new $className():
$server->withContainer($container);
It is strict by default: once a container is set, a controller missing from it raises
ControllerNotRegisteredException (HTTP 501) rather than being silently constructed
without its dependencies. For migrating an existing application, opt into the old
behaviour per call site and remove it once every controller is registered:
$server->withContainer($container, allowUnregistered: true);
| Container | Controller registered? | Result |
|---|---|---|
| not set | — | new $className() — unchanged |
| set | yes | resolved from the container |
| set | no (default) | ControllerNotRegisteredException |
| set | no, allowUnregistered: true | new $className() |
If the container returns something that is not an instance of the requested class, an
InvalidClassException names what came back instead of failing later as a missing method.
Not setting a container leaves the previous behaviour untouched, so this is backward
compatible. withContainer() lives on Server, not on ServerInterface, so custom
implementations of that interface are unaffected.
Adds a dependency on psr/container: ^2.0|^3.0.
See Dependency Injection.
BlockPathMiddleware
New middleware to restrict access by path, either by prefix or by regular expression:
$server->withMiddleware(
(new BlockPathMiddleware())
->withBlockedStartWith(['/admin'])
->withBlockedRegEx(['~^/internal/.*~'])
);
Static file serving
ServerStaticMiddlewarenow supportsdirectoryIndex, so a directory request can serve an index file.
Bug Fixes
- Accept header negotiation: all Accept types are now considered instead of only the
first, so a request whose primary type is unsupported (e.g.
image/avif) correctly falls back to*/*rather than returning 500. - Double-written error bodies: removed a spurious
processResponse()call before throwing 404/405, which produced invalid JSON such as[]{"error":...}. - 406 instead of 422: when no acceptable content type is found,
Error406Exceptionis raised — the correct RFC 7231 status. - Error status codes: the
ErrorHandlerfallback path now usesHttpResponseException::getStatusCode(), so non-500 exceptions are no longer reported as 500 when no output processor is set. HttpRequest::getHeader()normalizes header names and falls back to non-prefixed keys.- JSON output: uninitialized typed properties no longer break serialization (regression test added).
MockServerno longer reports the stale name "MockRequestHandler" in its error message.- After-middleware on a failed route:
afterProcess()declared the exception as?stringbut received the exception object, aTypeError. See Breaking Changes. - Path traversal in
ServerStaticMiddleware: a request path with..could serve any file the PHP process could read, and afile://request URI was served from its absolute path. Now only files inside the directory of the entry script are served; anything that resolves outside it (.., symlinks,file://) is a 404. - Path traversal in
UploadedFiles::saveTo(): the file name (the client's, when no new name is given) was appended to the destination as it came, so../app.phpwrote outside it. A name that is not a plain file name now throwsInvalidArgumentException. #[RouteDefinition]with PATCH, OPTIONS or any method other than GET/POST/PUT/DELETE: the route was built withRoute::$method(), which only exists for those four, so registering the class failed with "Call to undefined method". It now usesRoute::create().Error500Exceptionextended\Exceptioninstead ofHttpResponseException, unlike every otherError*Exception, and the status list had no 500 entry. It is now anHttpResponseExceptionwith status message "Internal Server Error". See Breaking Changes.ServerInterface::handle()declared$session = truewhileServerandMockServerdefault tofalse(the documented default). The interface now saysfalse.
Breaking Changes
Renamed classes
| Before (6.x) | After (7.0) |
|---|---|
ByJG\RestServer\HttpRequestHandler | ByJG\RestServer\Server |
ByJG\RestServer\RequestHandler | ByJG\RestServer\ServerInterface |
ByJG\RestServer\MockRequestHandler | ByJG\RestServer\MockServer |
ByJG\RestServer\ResponseBag | ByJG\RestServer\ResponseBody |
ByJG\RestServer\SerializationRuleEnum | ByJG\RestServer\Enum\OutputMode |
OutputMode also renames one case: Raw becomes Plain. The other cases
(Automatic, SingleObject, ObjectList) are unchanged.
HttpRequest accessors
The generic accessors were replaced by an explicit set that names the source it reads and
offers typed variants. Each *String / *Array method returns that type or null, which
removes the manual casting the old mixed returns required.
| Before (6.x) | After (7.0) |
|---|---|
get() | query() — plus queryString(), queryArray() |
post() | body() — plus bodyString(), bodyArray() |
request() | input() — plus inputString() |
param() | attribute() — plus attributeString() |
appendVars() | addAttributes() |
Also added: cookieString(), serverString(), sessionString().
Stricter return types
HttpRequest::getRequestPath()returns?stringinstead ofbool|array|string|null; aparse_url()failure now yieldsnull. Code guarding this value withis_array()can drop those checks.
Renamed methods
| Before (6.x) | After (7.0) |
|---|---|
HttpResponse::getResponseBag() | HttpResponse::getResponseBody() |
ResponseBag::setSerializationRule() | ResponseBody::serializeAs() |
ResponseBag::getSerializationRule() | ResponseBody::getOutputMode() |
AfterMiddlewareInterface::afterProcess() receives the exception
-
The last parameter is
?\Exception $exceptioninstead of?string $exception. The server always passed the exception object, so a route that threw while an after-middleware was registered failed with aTypeError. Change the signature in your after-middleware:public function afterProcess(HttpResponse $response, HttpRequest $request, string $class, string $method, ?\Exception $exception): MiddlewareResult
Changed exceptions and status codes
CorsMiddlewarerejects an origin withError403Exception(403) instead ofError401Exception(401).Error500Exceptionnow extendsHttpResponseExceptioninstead of\Exception. Acatch (HttpResponseException $e)now also catches it, and the response status line is500 Internal Server Errorinstead of500 Internal Error.InvalidClassExceptionnow extendsError500Exceptioninstead of\Exception, so it is anHttpResponseExceptionreported as 500.BaseOutputProcessor::getFromContentType()throwsError406Exceptioninstead ofOperationIdInvalidExceptionfor an unknown content type. This also applies whenOpenApiRouteListgenerates routes for a content type with no output processor.MockHttpRequestthrowsError400Exceptioninstead ofOperationIdInvalidExceptionfor a multipart part without a name.
Dependency updates
| Before (6.x) | After (7.0) |
|---|---|
byjg/webrequest: ^6.0 | byjg/webrequest: ^7.0 |
byjg/cache-engine: ^6.0 | byjg/cache-engine: ^7.0 |
| — | psr/container: ^2.0|^3.0 |
phpunit/phpunit (dev) | ^12.5.30 (security advisories) |
Enum cases renamed to PascalCase
Enum cases follow the ByJG standard and are written in PascalCase. The backed values did not
change, so ->value, from(), tryFrom() and stored data are not affected; only code that
names a case (or reads ->name) has to be updated.
| Enum | Before (6.x) | After (7.0) |
|---|---|---|
MiddlewareResult | stopProcessingOthers, stopProcessing, continue | StopProcessingOthers, StopProcessing, Continue |
Upgrade Path from 6.x to 7.x
Step 1: Update the dependency
composer require "byjg/restserver:^7.0"
composer update
Step 2: Rename the classes
// Before
use ByJG\RestServer\HttpRequestHandler;
$server = new HttpRequestHandler();
// After
use ByJG\RestServer\Server;
$server = new Server();
Apply the same for RequestHandler → ServerInterface, MockRequestHandler →
MockServer, ResponseBag → ResponseBody, and SerializationRuleEnum →
Enum\OutputMode (remembering Raw → Plain).
Then rename the methods: getResponseBag() → getResponseBody(),
setSerializationRule() → serializeAs() and getSerializationRule() → getOutputMode().
Step 3: Update the HttpRequest calls
Map each call through the table above. Where you previously cast the result, the typed variant now does it:
// Before
$id = (string)$request->get('id');
// After
$id = $request->queryString('id');
Step 4: Review error handling
If you asserted on specific status codes, note that an unacceptable content type is now 406 rather than 422, and that non-500 exceptions report their real status in the fallback path.
Step 5: (Optional) Adopt constructor injection
Register your controllers in a PSR-11 container and enable it in migration mode:
$server->withContainer($container, allowUnregistered: true);
Move controllers over one at a time, keeping tests green, then drop the flag to get the strict default.
For detailed documentation, visit: https://github.com/byjg/php-restserver
Requirements
- PHP 8.3, 8.4, 8.5 and 8.6 are now supported:
"php": ">=8.3 <8.7". The previous<8.6upper bound excluded PHP 8.6, since<8.6is exclusive.
ByJG dependencies
byjg/cache-engineis now^7.0.byjg/jwt-wrapperis now^7.0.byjg/serializeris now^7.0.byjg/singleton-patternis now^7.0.byjg/webrequestis now^7.0.
While 7.0 is unreleased these resolve to 7.0.x-dev from each component's
7.0 branch, via minimum-stability: dev with prefer-stable: true.
Toolchain
-
PHPUnit updated to
^12.5. -
Psalm is installed as
psalm/pharinstead ofvimeo/psalm.vimeo/psalmlists the PHP versions it supports and no published release includes 8.6, so as a dev dependency it madecomposer installfail on the 8.6 build job before any test ran.psalm/pharrequires onlyphp ^8.2and bundles its own dependencies, so it installs on every PHP version in the matrix and cannot conflict with the project's. Psalm itself still refuses to run on 8.6, which is why the Psalm job uses 8.5.composer psalmruns it. -
PHPUnit 13 is deliberately not used. It requires PHP
>=8.4.1, breaking the 8.3 floor.
Continuous Integration
- The build matrix now includes PHP 8.6.
- The Psalm job runs on PHP 8.5.
Housekeeping
phpunit.xml.distrenamed tophpunit.xml.