Skip to content

Building REST Controllers with the Winter Boot Framework

Winter Boot’s #[RestController] attribute transforms an ordinary PHP class into a fully managed HTTP handler. The framework discovers annotated classes automatically through namespace scanning, wires their dependencies, routes incoming requests to the correct method, and serialises the return value into an HTTP response — all without any XML, YAML, or routing-file configuration.

Apply #[RestController] at the class level to register the class as a REST endpoint handler. The class must be concrete (non-abstract), and its namespace must fall within one of the component-scan packages declared in your application configuration.

UserController.php
<?php
namespace dev\winterboot\samples\controller;
use dev\winterframework\stereotype\RestController;
#[RestController]
class UserController
{
// handler methods go here
}

Inject services, repositories, and other beans into your controller with #[Autowired]. Winter Boot resolves and injects the dependency at startup, so there is no manual container look-up at runtime.

UserController.php
<?php
namespace dev\winterboot\samples\controller;
use dev\winterboot\samples\service\UserService;
use dev\winterframework\stereotype\Autowired;
use dev\winterframework\stereotype\RestController;
#[RestController]
class UserController
{
#[Autowired]
protected UserService $userService;
// $this->userService is fully initialised before any request arrives
}

Add an HttpRequest parameter to any handler method and Winter Boot hands you the current request. Use it to read anything the parameter annotations (#[PathVariable], #[RequestParam], #[RequestBody]) do not cover — headers, cookies, the raw body, or details about the caller.

UserController.php
<?php
namespace dev\winterboot\samples\controller;
use dev\winterframework\stereotype\RestController;
use dev\winterframework\stereotype\web\GetMapping;
use dev\winterframework\web\http\HttpRequest;
#[RestController]
class UserController
{
// GET /whoami — echo back what the request carried
#[GetMapping(path: '/whoami')]
public function whoAmI(HttpRequest $req): array
{
return [
'method' => $req->getMethod(), // 'GET'
'uri' => $req->getUri(), // '/whoami'
'page' => $req->getQueryParam('page'),
'auth' => $req->getFirstHeader('Authorization'),
'sessionId' => $req->getCookie('SID'),
'contentType' => $req->getContentType(),
'body' => $req->getRawBody(),
];
}
}
What you call What it gives you
getMethod() HTTP method in upper case (GET, POST, …)
getUri() Request path without the query string
getQueryParam($name) / getQueryParams() One (or all) URL query values
getPostParam($name) / getPostParams() One (or all) form fields
getFirstHeader($name) / getHeader($name) First value (or all values) of a request header
getCookie($name) / getCookies() One (or all) request cookies
getRawBody() Request body as plain text
getContentType() Body content type, e.g. application/json
getFile($name) / getFiles() Uploaded files

These getters describe who called and where the request landed — the client address for localhost checks or logging, the server address for multi-homed hosts, and timing info. They return null when the runtime did not provide a value.

$ip = $req->getRemoteAddr(); // client IP, e.g. '127.0.0.1'
$port = $req->getRemotePort(); // client port
$serverIp = $req->getServerAddr(); // your server's IP
$protocol = $req->getServerProtocol(); // e.g. 'HTTP/1.1'
$query = $req->getQueryString(); // raw 'page=2&sort=name'
$when = $req->getRequestTime(); // unix timestamp

Controller methods can return three types of value. Winter Boot inspects the return value and sets the appropriate Content-Type header automatically.

Return type Behaviour
array Serialised to JSON; Content-Type: application/json
ResponseEntity Full control over status code, headers, and body
string Sent as-is; Content-Type: text/plain

ResponseEntity (namespace dev\winterframework\web\http\ResponseEntity) provides static factory methods to start a response, followed by fluent builder calls to attach a body or headers.

ResponseEntity factory methods
Method Description
ResponseEntity::ok() 200 OK
ResponseEntity::created(string $location) 201 Created, sets Location header
ResponseEntity::accepted() 202 Accepted
ResponseEntity::noContent() 204 No Content
ResponseEntity::badRequest() 400 Bad Request
ResponseEntity::unauthorized() 401 Unauthorized
ResponseEntity::notFound() 404 Not Found
ResponseEntity::unprocessableEntity() 422 Unprocessable Entity
ResponseEntity::status(HttpStatus $status) Any status from HttpStatus
ResponseEntity builder methods
Method Description
->withJson(mixed $body) Set body + Content-Type: application/json
->withXml(mixed $body) Set body + Content-Type: application/xml
->withHeader(string $name, string $value) Append a response header
->withStatusCode(int $code) Override status by numeric code
->withStatus(HttpStatus $status) Override status with an HttpStatus value
->withContentType(string $type) Set Content-Type without touching the body
->withContentLength(int $bytes) Set Content-Length (for file downloads)
->withCookie(name, value, expires, path, domain, secure, httponly) Send a cookie (see below)
->setBody(mixed $body) Set body without changing Content-Type
use dev\winterframework\web\http\ResponseEntity;
use dev\winterframework\web\http\HttpStatus;
// 200 OK with a JSON body
return ResponseEntity::ok()->withJson($user);
// 404 Not Found with no body
return ResponseEntity::notFound();
// 201 Created pointing to the new resource
return ResponseEntity::created('/users/42')->withJson($newUser);
// Custom status code
return ResponseEntity::status(HttpStatus::$CONFLICT)->withJson(['message' => 'Already exists']);

withCookie() sends a cookie with the response. Only name is required — everything else has a safe default:

Argument Meaning Default
name Cookie name (required)
value Cookie value ''
expires Expiry as a unix timestamp (time() + 3600 = one hour) 0 (browser-session cookie, deleted on close)
path Which site paths receive the cookie ''
domain Which domain receives it (empty = current domain) ''
secure Send over HTTPS only false
httponly Hide from JavaScript false
// Remember a theme for 30 days, visible across the whole site
return ResponseEntity::ok()
->withCookie('theme', 'dark', time() + 30 * 24 * 3600, '/')
->withJson(['status' => 'saved']);
// Log out: overwrite with an already-expired cookie so the browser drops it
return ResponseEntity::ok()
->withCookie('SID', '', time() - 3600, '/')
->withJson(['status' => 'logged-out']);

Call it once per cookie — each call adds another Set-Cookie header. Read the cookies a request carried with HttpRequest::getCookie($name) (see Reading the Request).

The controller below brings together every common pattern: #[Autowired] injection, all five HTTP method annotations, #[PathVariable], #[RequestBody], #[RequestParam], and both array and ResponseEntity return types.

UserController.php
<?php
namespace dev\winterboot\samples\doctrine\controller;
use dev\winterboot\samples\doctrine\model\User;
use dev\winterboot\samples\doctrine\service\UserService;
use dev\winterframework\stereotype\Autowired;
use dev\winterframework\stereotype\RestController;
use dev\winterframework\stereotype\web\DeleteMapping;
use dev\winterframework\stereotype\web\GetMapping;
use dev\winterframework\stereotype\web\PostMapping;
use dev\winterframework\stereotype\web\PutMapping;
use dev\winterframework\stereotype\web\PathVariable;
use dev\winterframework\stereotype\web\RequestBody;
use dev\winterframework\stereotype\web\RequestParam;
#[RestController]
class UserController
{
#[Autowired]
protected UserService $userService;
// GET /users — return all users as JSON
#[GetMapping(path: '/users')]
public function getAllUsers(): array
{
return [
'success' => true,
'data' => $this->userService->findAll(),
];
}
// GET /users/{id} — return a single user
#[GetMapping(path: '/users/{id}')]
public function getUserById(#[PathVariable] int $id): array
{
$user = $this->userService->findById($id);
if ($user) {
return ['success' => true, 'data' => $user];
}
return ['success' => false, 'message' => 'User not found'];
}
// POST /users — create a new user from a JSON body
#[PostMapping(path: '/users')]
public function createUser(#[RequestBody] User $user): array
{
try {
$existing = $this->userService->findByEmail($user->getEmail());
if ($existing) {
return [
'success' => false,
'message' => 'User with email ' . $user->getEmail() . ' already exists',
];
}
$created = $this->userService->createUser($user);
return [
'success' => true,
'data' => $created,
'message' => 'User created successfully',
];
} catch (\Exception $e) {
return ['success' => false, 'message' => 'Failed to create user: ' . $e->getMessage()];
}
}
// PUT /users/{id} — replace an existing user
#[PutMapping(path: '/users/{id}')]
public function updateUser(#[PathVariable] int $id, #[RequestBody] User $user): array
{
try {
$existing = $this->userService->findById($id);
if (!$existing) {
return ['success' => false, 'message' => 'User not found'];
}
$user->setId($id);
$updated = $this->userService->updateUser($user);
return [
'success' => true,
'data' => $updated,
'message' => 'User updated successfully',
];
} catch (\Exception $e) {
return ['success' => false, 'message' => 'Failed to update user: ' . $e->getMessage()];
}
}
// DELETE /users/{id}
#[DeleteMapping(path: '/users/{id}')]
public function deleteUser(#[PathVariable] int $id): array
{
try {
$deleted = $this->userService->deleteUser($id);
if ($deleted) {
return ['success' => true, 'message' => 'User deleted successfully'];
}
return ['success' => false, 'message' => 'User not found'];
} catch (\Exception $e) {
return ['success' => false, 'message' => 'Failed to delete user: ' . $e->getMessage()];
}
}
// GET /users/search?email=...
#[GetMapping(path: '/users/search')]
public function searchByEmail(#[RequestParam] string $email): array
{
$user = $this->userService->findByEmail($email);
if ($user) {
return ['success' => true, 'data' => $user];
}
return ['success' => false, 'message' => 'User not found'];
}
}
List all users
curl http://localhost/users
Get a single user
curl http://localhost/users/1
Create a user
curl -X POST http://localhost/users \
-H "Content-Type: application/json" \
-d '{"name":"John Doe","email":"john@example.com"}'
Update a user
curl -X PUT http://localhost/users/1 \
-H "Content-Type: application/json" \
-d '{"name":"Jane Doe","email":"jane@example.com"}'
Delete a user
curl -X DELETE http://localhost/users/1
Search by email
curl "http://localhost/users/search?email=john@example.com"

When an unhandled error or exception occurs, Winter Boot delegates to an ErrorController implementation. The ErrorController interface lives in dev\winterframework\core\web\error\ErrorController and exposes a single method:

interface ErrorController
{
public function handleError(
HttpRequest $request,
ResponseEntity $response,
HttpStatus $status,
?Throwable $t = null
): void;
}

Register your implementation under the bean name errorController using either of the two approaches below.

MyErrorController.php
<?php
use dev\winterframework\core\web\error\ErrorController;
use dev\winterframework\stereotype\Component;
use dev\winterframework\web\http\HttpRequest;
use dev\winterframework\web\http\HttpStatus;
use dev\winterframework\web\http\ResponseEntity;
#[Component('errorController')]
class MyErrorController implements ErrorController
{
public function handleError(
HttpRequest $request,
ResponseEntity $response,
HttpStatus $status,
?Throwable $t = null
): void {
$response->withStatusCode($status->getValue())
->withJson([
'error' => $status->getReasonPhrase(),
'message' => $t?->getMessage(),
]);
}
}