In this page:
- Introduction
- Main Classes
- Creating Services with Annotations
- Authentication and Authorization
- Creating Services Traditionally
- Response Handling
- OpenAPI Documentation
- Testing Web Services
- Calling Services
Web services play important role when it comes to sending and receiving data in the web. They can be used to perform CRUD operations on a database server without having to look at implementation details from the front end side.
WebFiori framework provides the very basic level of utilities at which it can be used to implement web services that supports data filtering and validation. The library WebFiori HTTP is one of the core libraries of the framework that can be used to create RESTful APIs in simple manner. In addition to that, this library provides abstraction when it comes to collecting server output and sending it back to web clients.
Note: This library can be used as a stand alone library using composer by including this entry in the
requirepart of thecomposer.jsonfile:"webfiori/http":"*".
| Class | Description |
|---|---|
WebService |
Modern service class with annotation support |
AbstractWebService |
Traditional base class for services |
WebServicesManager |
Manages and routes requests to services (traditional) |
ServiceRouter |
Auto-discovers and registers service routes (recommended) |
RequestProcessor |
Processes a single service against a request |
RequestParameter |
Represents a request parameter |
SecurityContext |
Manages authentication state |
APITestCase |
PHPUnit helper for testing APIs |
The recommended approach for creating web services uses PHP 8 attributes for clean, declarative API definitions.
namespace App\Apis;
use WebFiori\Http\Annotations\AllowAnonymous;
use WebFiori\Http\Annotations\GetMapping;
use WebFiori\Http\Annotations\ResponseBody;
use WebFiori\Http\Annotations\RestController;
use WebFiori\Http\WebService;
#[RestController('users', 'User management API')]
class UserService extends WebService {
#[GetMapping]
#[ResponseBody]
#[AllowAnonymous]
public function getUsers(): array {
return [
'users' => [
['id' => 1, 'name' => 'John'],
['id' => 2, 'name' => 'Jane']
]
];
}
}Key annotations:
| Annotation | Target | Description |
|---|---|---|
#[RestController] |
Class | Defines service name, description, and path |
#[GetMapping] |
Method | Maps to GET requests |
#[PostMapping] |
Method | Maps to POST requests |
#[PutMapping] |
Method | Maps to PUT requests |
#[DeleteMapping] |
Method | Maps to DELETE requests |
#[PatchMapping] |
Method | Maps to PATCH requests |
#[ResponseBody] |
Method | Auto-converts return value to JSON |
#[RequestParam] |
Method | Declares a request parameter |
#[ApiResponse] |
Method | Declares a possible response for OpenAPI spec (repeatable) |
#[AllowAnonymous] |
Method | Skips authentication check |
#[RequiresAuth] |
Method | Requires authentication |
#[PreAuthorize] |
Method/Class | Expression-based authorization |
#[Validate] |
Method | Cross-field validation function |
#[UseParameterSet] |
Method | Imports a reusable parameter set |
#[Produces] |
Method | Content negotiation (Accept header matching) |
#[Consumes] |
Method | Per-method content type control (request body types) |
Define parameters using the #[RequestParam] annotation:
use WebFiori\Http\Annotations\GetMapping;
use WebFiori\Http\Annotations\RequestParam;
use WebFiori\Http\Annotations\ResponseBody;
use WebFiori\Http\Annotations\RestController;
use WebFiori\Http\WebService;
#[RestController('products', 'Product API')]
class ProductService extends WebService {
#[GetMapping]
#[ResponseBody]
#[RequestParam(name: 'id', type: 'int')]
#[RequestParam(name: 'include_details', type: 'bool', optional: true, default: false)]
public function getProduct(): array {
$id = $this->getParamVal('id');
$includeDetails = $this->getParamVal('include_details');
return [
'product' => [
'id' => $id,
'name' => 'Sample Product',
'details' => $includeDetails ? ['weight' => '1kg'] : null
]
];
}
}Parameter options:
| Option | Type | Description |
|---|---|---|
name |
string | Parameter name (required) |
type |
string | Data type: string, int, float, bool, email, url, array |
optional |
bool | Whether parameter is optional (default: false) |
default |
mixed | Default value if not provided |
description |
string | Parameter description for documentation |
The #[MapEntity] annotation automatically maps request parameters to an object:
use WebFiori\Http\Annotations\MapEntity;
use WebFiori\Http\Annotations\PostMapping;
use WebFiori\Http\Annotations\ResponseBody;
use WebFiori\Http\Annotations\RestController;
use WebFiori\Http\WebService;
#[RestController('users', 'User API')]
class UserService extends WebService {
#[PostMapping]
#[ResponseBody]
#[MapEntity(User::class)]
public function createUser(User $user): array {
// $user is automatically populated from request body
// Request: {"name": "John", "email": "john@example.com", "age": 30}
return [
'message' => 'User created',
'user' => $user->toArray()
];
}
}The mapper matches request parameters to setter methods:
name→setName()email→setEmail()user_age→setUserAge()
For custom parameter-to-setter mapping:
#[MapEntity(User::class, setters: [
'full-name' => 'setName',
'email-address' => 'setEmail'
])]
public function createUser(User $user): array {
// Maps 'full-name' param to setName(), 'email-address' to setEmail()
}Alternative: Use getObject() method:
public function createUser(): array {
$user = $this->getObject(User::class);
// ...
}Use the #[Validate] attribute to run custom validation logic after parameters are filtered:
use WebFiori\Http\Annotations\Validate;
#[PostMapping]
#[ResponseBody]
#[RequestParam(name: 'password', type: 'string')]
#[RequestParam(name: 'password_confirm', type: 'string')]
#[RequestParam(name: 'email', type: 'email')]
#[Validate('validateRegistration')]
public function register(): array {
// Only reached if validation passes
return ['message' => 'User registered'];
}
private function validateRegistration(array $inputs): array {
$errors = [];
if ($inputs['password'] !== $inputs['password_confirm']) {
$errors['password_confirm'] = 'Passwords do not match.';
}
if (strlen($inputs['password']) < 8) {
$errors['password'] = 'Password must be at least 8 characters.';
}
return $errors; // Empty array = valid
}If the validation method returns a non-empty array, the framework sends a 422 response with the errors.
Use the ParameterSet interface and #[UseParameterSet] attribute to share parameter definitions across services:
use WebFiori\Http\ParameterSet;
use WebFiori\Http\ParamOption;
use WebFiori\Http\ParamType;
class PaginationParams implements ParameterSet {
public function getParameters(): array {
return [
'page' => [
ParamOption::TYPE => ParamType::INT,
ParamOption::OPTIONAL => true,
ParamOption::DEFAULT => 1
],
'per-page' => [
ParamOption::TYPE => ParamType::INT,
ParamOption::OPTIONAL => true,
ParamOption::DEFAULT => 20
]
];
}
}Then use it on any method:
use WebFiori\Http\Annotations\UseParameterSet;
#[GetMapping]
#[ResponseBody]
#[UseParameterSet(PaginationParams::class)]
#[RequestParam(name: 'category', type: 'string', optional: true)]
public function listProducts(): array {
$page = $this->getParamVal('page');
$perPage = $this->getParamVal('per-page');
// ...
}Use #[Produces] to declare what content types a method can return. The framework matches against the client's Accept header:
use WebFiori\Http\Annotations\Produces;
use WebFiori\Http\MediaType;
#[GetMapping]
#[Produces(MediaType::JSON, MediaType::XML)]
public function getReport(): ResponseEntity {
// Framework returns 406 Not Acceptable if client doesn't accept JSON or XML
return ResponseEntity::ok(['report' => '...']);
}Available MediaType constants: JSON, XML, HTML, PLAIN, CSV, PDF, FORM, MULTIPART, OCTET_STREAM.
Use #[Consumes] to declare which content types a method accepts in the request body. This overrides the default allowed types (application/x-www-form-urlencoded, multipart/form-data, application/json) for POST and PUT requests:
use WebFiori\Http\Annotations\Consumes;
use WebFiori\Http\Annotations\PostMapping;
use WebFiori\Http\Annotations\ResponseBody;
use WebFiori\Http\MediaType;
use WebFiori\Http\ResponseEntity;
#[PostMapping]
#[Consumes(MediaType::OCTET_STREAM)]
#[ResponseBody]
public function uploadBinary(): ResponseEntity {
// Only application/octet-stream is accepted
// Default types (form, json) are REJECTED
$body = file_get_contents('php://input');
$size = strlen($body);
return ResponseEntity::created(['size' => $size, 'md5' => md5($body)]);
}Key behaviors:
- No
#[Consumes]: The default three types apply (backward compatible). - With
#[Consumes]: Only the listed types are accepted. The defaults are overridden, not merged. - Non-standard types: When a non-parseable content type is used (not form-encoded, multipart, or JSON), parameter filtering is automatically skipped. The raw body is available via
php://input. - Standard types in
#[Consumes]: If you list a parseable type (e.g.MediaType::FORM), normal parameter filtering still applies for requests using that type.
Multiple content types on one method:
#[PostMapping]
#[Consumes(MediaType::OCTET_STREAM, MediaType::XML, 'text/xml')]
#[ResponseBody]
public function acceptMultiple(): ResponseEntity {
$contentType = $this->getManager()->getRequest()->getContentType();
$body = file_get_contents('php://input');
// Handle based on actual content type received
if (str_contains($contentType, 'xml')) {
return $this->handleXml($body);
}
return $this->handleBinary($body);
}Mixing standard and non-standard types:
#[PostMapping]
#[Consumes(MediaType::FORM, MediaType::OCTET_STREAM)]
#[ResponseBody]
public function flexible(): ResponseEntity {
$contentType = $this->getManager()->getRequest()->getContentType();
if (str_contains($contentType, 'octet-stream')) {
// Raw binary — no parameter filtering
$body = file_get_contents('php://input');
return ResponseEntity::ok(['raw_size' => strlen($body)]);
}
// Form-encoded — normal parameter filtering applies
$name = $this->getParamVal('name');
return ResponseEntity::ok(['name' => $name]);
}Note:
#[Consumes]only affects POST and PUT requests. GET, DELETE, and PATCH requests bypass content type validation regardless of the annotation.
For more control over HTTP status codes and content types from #[ResponseBody] methods, return a ResponseEntity:
use WebFiori\Http\ResponseEntity;
#[PostMapping]
#[ResponseBody]
public function createUser(User $user): ResponseEntity {
$this->userRepo->save($user);
return ResponseEntity::created(['user' => $user->toArray()]);
}
#[GetMapping]
#[ResponseBody]
#[RequestParam(name: 'id', type: 'int')]
public function getUser(): ResponseEntity {
$user = $this->userRepo->findById($this->getParamVal('id'));
if ($user === null) {
return ResponseEntity::notFound(['error' => 'User not found']);
}
return ResponseEntity::ok(['user' => $user->toArray()]);
}
#[DeleteMapping]
#[ResponseBody]
#[RequestParam(name: 'id', type: 'int')]
public function deleteUser(): ResponseEntity {
$this->userRepo->deleteById($this->getParamVal('id'));
return ResponseEntity::noContent();
}Static factory methods:
| Method | HTTP Status |
|---|---|
ResponseEntity::ok($body) |
200 |
ResponseEntity::created($body) |
201 |
ResponseEntity::noContent() |
204 |
ResponseEntity::badRequest($body) |
400 |
ResponseEntity::unauthorized($body) |
401 |
ResponseEntity::forbidden($body) |
403 |
ResponseEntity::notFound($body) |
404 |
ResponseEntity::error($body) |
500 |
Or construct directly with any status code:
return new ResponseEntity(['data' => $value], 202, 'application/json');#[RestController('admin', 'Admin API')]
class AdminService extends WebService {
#[GetMapping]
#[ResponseBody]
#[AllowAnonymous] // No authentication required
public function getPublicInfo(): array {
return ['status' => 'online'];
}
#[GetMapping]
#[ResponseBody]
#[RequiresAuth] // Authentication required
public function getSecretData(): array {
return ['secret' => 'classified'];
}
}For fine-grained authorization based on roles and authorities, use #[PreAuthorize] with security expressions:
use WebFiori\Http\Annotations\PreAuthorize;
#[RestController('admin', 'Admin API')]
class AdminService extends WebService {
#[GetMapping]
#[ResponseBody]
#[PreAuthorize("hasRole('ADMIN')")]
public function getAdminDashboard(): array {
return ['dashboard' => '...'];
}
#[DeleteMapping]
#[ResponseBody]
#[PreAuthorize("hasRole('ADMIN') && hasAuthority('DELETE_USER')")]
public function deleteUser(): array {
// Only admins with DELETE_USER authority can reach this
return ['message' => 'User deleted'];
}
#[GetMapping]
#[ResponseBody]
#[PreAuthorize("hasAnyRole('USER', 'ADMIN')")]
public function getProfile(): array {
return ['profile' => '...'];
}
}Supported expressions:
hasRole('ROLE_NAME')— user has the specified rolehasAnyRole('ROLE1', 'ROLE2')— user has at least one of the roleshasAuthority('AUTHORITY')— user has the specified authorityisAuthenticated()— user is authenticated- Logical operators:
&&,||,!
#[PreAuthorize] can also be applied at the class level to apply to all methods.
public function isAuthorized(): bool {
$authHeader = $this->getAuthHeader();
if ($authHeader === null) {
return false;
}
$scheme = $authHeader->getScheme(); // 'basic', 'bearer', etc.
$credentials = $authHeader->getCredentials(); // Token or encoded credentials
if ($scheme === 'bearer') {
return $this->validateToken($credentials);
}
if ($scheme === 'basic') {
$decoded = base64_decode($credentials);
[$username, $password] = explode(':', $decoded, 2);
return $this->validateUser($username, $password);
}
return false;
}Use SecurityContext for managing authentication state:
use WebFiori\Http\SecurityContext;
use WebFiori\Http\SecurityPrincipal;
// Set authenticated user (using a class that implements SecurityPrincipal)
$user = new AppUser(); // Must implement SecurityPrincipal interface
SecurityContext::setCurrentUser($user);
// Check authentication
if (SecurityContext::isAuthenticated()) {
$currentUser = SecurityContext::getCurrentUser();
}
// Evaluate security expressions
SecurityContext::evaluateExpression("hasRole('ADMIN')");
SecurityContext::evaluateExpression("hasAnyRole('USER', 'ADMIN')");
SecurityContext::evaluateExpression("isAuthenticated()");
SecurityContext::evaluateExpression("hasRole('ADMIN') && hasAuthority('DELETE_USER')");For more control, use the traditional approach by extending AbstractWebService.
use WebFiori\Http\AbstractWebService;
use WebFiori\Http\RequestMethod;
use WebFiori\Http\ParamOption;
use WebFiori\Http\ParamType;
class GetRandomService extends AbstractWebService {
public function __construct() {
parent::__construct('get-random-number');
$this->addRequestMethod(RequestMethod::GET);
$this->addParameters([
'min' => [
ParamOption::TYPE => ParamType::INT,
ParamOption::OPTIONAL => true
],
'max' => [
ParamOption::TYPE => ParamType::INT,
ParamOption::OPTIONAL => true
]
]);
}
public function isAuthorized(): bool {
return true;
}
public function processRequest() {
$min = $this->getParamVal('min') ?? 0;
$max = $this->getParamVal('max') ?? 100;
$this->sendResponse(rand($min, $max));
}
}use WebFiori\Http\WebServicesManager;
class RandomAPI extends WebServicesManager {
public function __construct() {
parent::__construct();
$this->addService(new GetRandomService());
}
}
// Process request
$manager = new RandomAPI();
$manager->process();Auto-discover services in a directory:
$manager = new WebServicesManager();
$manager->autoDiscoverServices(); // Discovers services in current directory
$manager->process();#[GetMapping]
#[ResponseBody]
public function getData(): array {
return ['key' => 'value']; // Automatically converted to JSON
}public function processRequest() {
// JSON response
$this->send('application/json', ['data' => 'value']);
// Plain text
$this->send('text/plain', 'Hello World');
// XML response
$xml = '<?xml version="1.0"?><root><item>value</item></root>';
$this->send('application/xml', $xml);
// With status code
$this->sendResponse('Resource created', 201, self::I);
// Error response
$this->sendResponse('Not found', 404, self::E);
}Response type constants:
self::I- Info messageself::S- Success messageself::E- Error message
The library generates OpenAPI 3.1.0 specifications automatically from your service definitions.
Use the repeatable #[ApiResponse] attribute to declare possible responses on each method:
use WebFiori\Http\Annotations\ApiResponse;
#[GetMapping]
#[ResponseBody]
#[ApiResponse(status: '200', description: 'List of products')]
#[ApiResponse(status: '404', description: 'Product not found')]
#[RequestParam(name: 'id', type: 'int', optional: true)]
public function getProducts(?int $id): array {
// ...
}
#[PostMapping]
#[ResponseBody]
#[ApiResponse(status: '201', description: 'Product created')]
#[ApiResponse(status: '400', description: 'Invalid input')]
#[RequestParam(name: 'name', type: 'string')]
public function createProduct(string $name): array {
// ...
}If no #[ApiResponse] is present, the spec defaults to 200 - Successful operation. Programmatic addResponse() calls take priority over annotations.
Use the path property on #[RestController] to set a multi-segment URL path independent of the service name:
#[RestController(name: 'login', path: 'auth/login', description: 'Authentication endpoint')]
class LoginService extends WebService {
// Route: /apis/auth/login
// Service name: login (used for lookups)
}name— service identifier (no slashes, used for internal lookups)path— URL mount point (slashes allowed, used in OpenAPI spec and routing)- If
pathis not set, the service name is used as the path
Use OpenAPISpecService to expose your spec as a live endpoint without writing boilerplate:
use WebFiori\Http\OpenAPI\OpenAPISpecService;
use WebFiori\Http\RequestProcessor;
$specService = new OpenAPISpecService(
'App\\Apis', // Namespace to scan for #[RestController] classes
'/apis', // Base path prefix in the spec
'My API', // API title
'1.0.0' // API version
);
$processor = new RequestProcessor();
$processor->process($specService);Point Swagger UI at this endpoint to get auto-generated, always-current documentation.
Use OpenAPIGenerator to discover services and generate specs without manual registration:
use WebFiori\Http\OpenAPI\OpenAPIGenerator;
$generator = new OpenAPIGenerator();
// Auto-discover all #[RestController] classes in a namespace
$spec = $generator->generateFromNamespace(
'App\\Apis',
'My API',
'1.0.0',
'/apis'
);
echo $spec->toJSON();Or use the explicit approach for full control:
$spec = $generator->generate(
[new UserService(), new ProductService()],
'My API',
'1.0.0',
'/apis'
);When a #[ResponseBody] method returns a JsonI object (such as the OpenAPI spec itself), the framework serializes it directly via toJSON() without wrapping or metadata:
use WebFiori\Json\JsonI;
#[GetMapping]
#[ResponseBody]
#[AllowAnonymous]
public function getSpec(): JsonI {
$generator = new OpenAPIGenerator();
return $generator->generateFromNamespace('App\\Apis', 'My API', '1.0.0', '/apis');
}The generated spec can be used with:
- Swagger UI
- Postman (import as OpenAPI)
- Other OpenAPI-compatible tools
Use APITestCase for unit testing:
use WebFiori\Http\APITestCase;
class UserServiceTest extends APITestCase {
public function testGetUsers() {
$manager = new UserAPI();
$output = $this->getRequest($manager, 'get-users', [
'page' => 1,
'limit' => 10
]);
$response = json_decode($output, true);
$this->assertArrayHasKey('users', $response);
}
public function testCreateUser() {
$manager = new UserAPI();
$output = $this->postRequest($manager, 'create-user', [
'name' => 'John',
'email' => 'john@example.com'
]);
$response = json_decode($output, true);
$this->assertEquals('User created', $response['message']);
}
public function testUpdateUser() {
$manager = new UserAPI();
$output = $this->putRequest($manager, 'update-user', [
'id' => 1,
'name' => 'John Updated'
]);
$response = json_decode($output, true);
$this->assertEquals('User updated', $response['message']);
}
public function testDeleteUser() {
$manager = new UserAPI();
$output = $this->deleteRequest($manager, 'delete-user', [
'id' => 1
]);
$response = json_decode($output, true);
$this->assertEquals('User deleted', $response['message']);
}
public function testWithAuthentication() {
$manager = new UserAPI();
$output = $this->getRequest(
$manager,
'get-profile',
[],
['Authorization' => 'Bearer test-token']
);
$this->assertStringContainsString('profile', $output);
}
public function testFileUpload() {
$manager = new FileAPI();
$this->addFile('document', '/path/to/test.pdf');
$output = $this->postRequest($manager, 'upload-file', [
'description' => 'Test file'
]);
$response = json_decode($output, true);
$this->assertEquals('File uploaded', $response['message']);
}
}Available test methods:
| Method | Description |
|---|---|
getRequest($manager, $service, $params, $headers) |
Simulate a GET request |
postRequest($manager, $service, $params, $headers) |
Simulate a POST request |
putRequest($manager, $service, $params, $headers) |
Simulate a PUT request |
deleteRequest($manager, $service, $params, $headers) |
Simulate a DELETE request |
addFile($name, $path) |
Add a file for upload testing |
Use ServiceRouter::discover() to auto-register all services in a namespace:
use WebFiori\Framework\Router\ServiceRouter;
use WebFiori\Framework\Router\RouteOption;
class APIsRoutes {
public static function create() {
ServiceRouter::discover('App\\Apis', '/apis', [
RouteOption::MIDDLEWARE => ['start-session', 'csrf']
]);
}
}This scans App/Apis/ and registers a route per service:
#[RestController(name: 'orders', path: 'shop/orders')]→GET|POST /apis/shop/orders#[RestController('orders')]→GET|POST /apis/orders#[RestController]onProductService→GET|POST /apis/productWebServicewithout attribute (getName() = 'users') →GET|POST /apis/usersWebServicesManagersubclass → registered as traditional manager route
Recursive scanning discovers nested directories:
ServiceRouter::discover('App\\Apis', '/apis', [], null, recursive: true);
// App/Apis/Admin/UserService.php → /apis/admin/user
// App/Apis/Auth/LoginService.php → /apis/auth/loginDirectory names are converted to kebab-case (UserAuth → user-auth).
Dynamic resolution (no restart needed for new services):
ServiceRouter::dynamic('App\\Apis', '/apis/{controller}', [
RouteOption::MIDDLEWARE => ['start-session']
]);
// Request to /apis/orders → resolves OrderService at runtimeList discovered services:
php webfiori services:listServices are called via HTTP with the service parameter:
GET https://example.com/api?service=get-users
POST https://example.com/api?service=create-user
Create a route to your services manager:
Router::api([
'path' => '/api/{service}',
'route-to' => UserAPI::class
]);Then call: https://example.com/api/get-users
Note: Both approaches work side by side. Existing
WebServicesManagerroutes are unchanged.
- MVC Architecture - Build APIs with Controllers, Repositories, and Entities
- Database Management - Use database in web services
- Middleware - Protect API endpoints with middleware
- Routing - Create API routes
- The Library WebFiori JSON - Format API responses as JSON
- The Class Response - Send API responses