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.
Registering a Controller
Section titled “Registering a Controller”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.
<?php
namespace dev\winterboot\samples\controller;
use dev\winterframework\stereotype\RestController;
#[RestController]class UserController{ // handler methods go here}Injecting Dependencies
Section titled “Injecting Dependencies”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.
<?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}Reading the Request
Section titled “Reading the Request”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.
<?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 |
Caller and server details
Section titled “Caller and server details”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 timestampReturn Types
Section titled “Return Types”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 Builder API
Section titled “ResponseEntity Builder API”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 bodyreturn ResponseEntity::ok()->withJson($user);
// 404 Not Found with no bodyreturn ResponseEntity::notFound();
// 201 Created pointing to the new resourcereturn ResponseEntity::created('/users/42')->withJson($newUser);
// Custom status codereturn ResponseEntity::status(HttpStatus::$CONFLICT)->withJson(['message' => 'Already exists']);Setting cookies
Section titled “Setting cookies”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 sitereturn ResponseEntity::ok() ->withCookie('theme', 'dark', time() + 30 * 24 * 3600, '/') ->withJson(['status' => 'saved']);
// Log out: overwrite with an already-expired cookie so the browser drops itreturn 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).
Complete CRUD Example
Section titled “Complete CRUD Example”The controller below brings the common patterns together: #[Autowired] injection, the GET/POST/PUT/DELETE mapping attributes, #[PathVariable], #[RequestBody], and #[RequestParam].
<?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']; }}Testing with curl
Section titled “Testing with curl”curl http://localhost:8080/userscurl http://localhost:8080/users/1curl -X POST http://localhost:8080/users \ -H "Content-Type: application/json" \ -d '{"name":"John Doe","email":"john@example.com"}'curl -X PUT http://localhost:8080/users/1 \ -H "Content-Type: application/json" \ -d '{"name":"Jane Doe","email":"jane@example.com"}'curl -X DELETE http://localhost:8080/users/1curl "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
OPTIONSrequest withOriginandAccess-Control-Request-Methodbefore a “non-simple” call (JSON body,Authorizationheader,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 gets204with theAccess-Control-Allow-*headers; anything else gets403with no CORS header. You do not writeOPTIONSroutes. - 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 outsideallowedMethods, which simpleGET/POSTrequests 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 anOriginheader carriesVary: Origin. - Matching. Origins match exactly on scheme, host and port (case-insensitive);
https://app.example.com.evil.ioandnullnever match. The response carries the matched allowlist entry in lowercase (the form browsers send), not the raw request value. - Credentials.
allowedOrigins: ['*']together withallowCredentials: trueis refused (CORS stays off, one error is logged). List the origins explicitly when cookies orAuthorizationare involved.
Per-controller and per-route policy
Section titled “Per-controller and per-route policy”#[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.corsis 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 byAccess-Control-Request-Methodon the same path, so list the sameoriginsfor 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 withallowCredentials: trueis refused for that route: CORS stays off there and an error is logged.
Error Handling
Section titled “Error Handling”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.
<?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(), ]); }}<?php
use dev\winterframework\core\web\error\ErrorController;use dev\winterframework\stereotype\Bean;use dev\winterframework\stereotype\Configuration;
#[Configuration]class AppConfig{ #[Bean('errorController')] public function getErrorController(): ErrorController { return new MyErrorController(); }}