Skip to content

Building REST Controllers with the Winter Boot Framework

The #[RestController] attribute turns an ordinary PHP class into an HTTP handler. Winter Boot finds annotated classes during namespace scanning, injects their dependencies, routes requests to the right method, and converts the return value into an HTTP response — no routing files needed.

Apply #[RestController] at the class level to register the class as a REST endpoint handler. The class must be concrete (not abstract), and its namespace must be inside one of the scanNamespaces declared on your #[WinterBootApplication] class.

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 injected 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; the name is case-insensitive (user-agent and User-Agent are the same 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

Winter Boot looks at what a handler returns and sets the Content-Type header for you (unless you set one yourself).

Return value Behavior
array or JsonSerializable object Encoded as JSON; Content-Type: application/json
string, int, float, bool Sent as-is; Content-Type: text/plain
SplFileInfo Streamed as a file download, with Content-Type, Content-Length, and Content-Disposition set
ResponseEntity Full control over status code, headers, and body

JSON bodies are compact by default. To indent them (easier to read by hand, more bytes on the wire), set winter.web.json.prettyPrint: true in application.yml.

ResponseEntity (dev\winterframework\web\http\ResponseEntity) has static factory methods to start a response, and chainable methods to add a body, headers, or cookies.

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
samesite Lax, Strict, None (needs secure) or '' to leave it out ''
// 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 the common patterns together: #[Autowired] injection, the GET/POST/PUT/DELETE mapping attributes, #[PathVariable], #[RequestBody], and #[RequestParam].

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:8080/users
Get a single user
curl http://localhost:8080/users/1
Create a user
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"John Doe","email":"john@example.com"}'
Update a user
curl -X PUT http://localhost:8080/users/1 \
-H "Content-Type: application/json" \
-d '{"name":"Jane Doe","email":"jane@example.com"}'
Delete a user
curl -X DELETE http://localhost:8080/users/1
Search by email
curl "http://localhost:8080/users/search?email=john@example.com"

Browsers only let a page on one origin read API responses from another origin if the API opts in. Winter Boot sends no CORS headers unless you configure an origin allowlist, so by default cross-origin browser calls are blocked. Serving the UI and the API from one origin (see Build a UI) needs none of this.

winter:
web:
cors:
allowedOrigins:
- https://app.example.com
- http://localhost:5173
allowedHeaders: [Content-Type, Authorization]
exposedHeaders: [X-Total-Count]
allowCredentials: false
maxAge: 1800
  • Preflight. A browser sends an OPTIONS request with Origin and Access-Control-Request-Method before a “non-simple” call (JSON body, Authorization header, PUT/DELETE). The dispatcher answers it itself, before routing, interceptors and authentication, because browsers send preflights without credentials. An allowed origin, method and header set gets 204 with the Access-Control-Allow-* headers; anything else gets 403 with no CORS header. You do not write OPTIONS routes.
  • Actual requests. A request from an allowed origin using an allowed method gets Access-Control-Allow-Origin (plus credentials and expose headers when configured) on every response, error responses included. A request from another origin (or with a method outside allowedMethods, which simple GET/POST requests can use without a preflight) is still served, but without those headers the browser withholds the response from the page. Every response to a request with an Origin header carries Vary: Origin.
  • Matching. Origins match exactly on scheme, host and port (case-insensitive); https://app.example.com.evil.io and null never match. The response carries the matched allowlist entry in lowercase (the form browsers send), not the raw request value.
  • Credentials. allowedOrigins: ['*'] together with allowCredentials: true is refused (CORS stays off, one error is logged). List the origins explicitly when cookies or Authorization are involved.

#[CrossOrigin] on a controller class or method overrides the global policy for those routes. Each field you leave out inherits the next level up: method, then class, then winter.web.cors.*.

use dev\winterframework\stereotype\web\CrossOrigin;
#[RestController]
#[CrossOrigin(origins: ['https://partner.example.com'])]
class ReportController {
#[GetMapping(path: '/reports')]
public function list(): array { /* partner.example.com only */ }
#[PostMapping(path: '/reports')]
#[CrossOrigin(origins: ['https://app.example.com'], allowCredentials: true, maxAge: 300)]
public function create(): array { /* app.example.com, with credentials */ }
}

Parameters: origins, methods, headers, exposedHeaders (lists), allowCredentials (bool) and maxAge (seconds). An empty list or an omitted value inherits.

  • The attribute alone switches CORS on for the route, even when winter.web.cors is not configured. With no origin at any level (its own or global), the route sends no CORS header.
  • Like the global config, it never rejects a request. It only decides which CORS headers are sent, and a preflight from a disallowed origin gets 403.
  • A preflight (OPTIONS) uses the policy of the route named by Access-Control-Request-Method on the same path, so list the same origins for every method of a path that browsers call.
  • A route inherited from a base controller runs on the base class’s bean, so put #[CrossOrigin] on the base class or on the method. An attribute on the subclass has no effect on inherited routes.
  • origins: ['*'] together with allowCredentials: true is refused for that route: CORS stays off there and an error is logged.

When a request fails (an unhandled exception, an unknown route, a bad request body), Winter Boot hands it to the errorController bean. The built-in DefaultErrorController returns a JSON error; to customize the response, implement the dev\winterframework\core\web\error\ErrorController interface:

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

Register your implementation under the bean name errorController, either as a component or from a #[Bean] method.

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(),
]);
}
}