API Controllers
As of OXID eShop version 7.5, you can create RESTful API endpoints using Symfony routing attributes.
The new api.php entry point provides a lightweight alternative to traditional OXID controllers
for building JSON APIs, microservices, and headless commerce solutions.
Unlike view controllers, API controllers don’t render templates and can return structured data (JSON, XML, etc.) directly to the client.
Overview
API controllers leverage:
Symfony HttpKernel for request/response handling
Symfony Routing with PHP attributes for URL mapping
Dependency Injection Container for service management
Route auto-discovery from public services
Key differences from view controllers:
API controllers |
View controllers |
|
|---|---|---|
Entry point |
|
|
Routing |
|
|
Base class |
None — pure PHP class, can be |
Must extend |
Request access |
Explicit |
Static |
Output |
Return a |
Return a template name string |
Error responses |
JSON with HTTP status code |
HTML error page |
Session |
Stateless by default; opt-in |
Always started |
Creating a Simple API Endpoint
Controller Class
Create a simple API controller that returns a list of active products:
Example
<?php
declare(strict_types=1);
namespace MyVendor\MyModule\Controller\Api;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
readonly class ProductApiController
{
public function __construct(
private ProductServiceInterface $productService
) {
}
#[Route('/api/products', methods: ['GET'])]
public function listProducts(): Response
{
$products = $this->productService->getActiveProducts();
return new JsonResponse([
'products' => $products,
'total' => count($products),
]);
}
}
Note
API controllers don’t need to extend BaseController. They can be simple readonly classes that accept dependencies via constructor injection.
Important
All API routes must start with /api/ prefix.
This is enforced by two runtime layers, not by the #[Route] attribute syntax:
The shop’s
source/.htaccesscontainsRewriteRule ^api/(.*)$ api.php/$1 [QSA,NC,L]— this is what routes incoming HTTP requests to theapi.phpentry point in the first place. Without this rewrite, the URL never reaches Symfony’s routing.The API rate limiter (see API Rate Limiting) treats only requests whose path starts with
/api/as API requests. Routes outside this prefix bypass rate limiting entirely.
Service Registration
Register your API controller as a public service in any services.yaml that the DI container loads — module-level, an imported sub-file, or a component file.
Example
MyVendor\MyModule\Controller\Api\ProductApiController:
public: true
The controller must be public so that the RoutePass compiler pass can discover its routes.
Note
Do not place overrides for shared API parameters (for example
oxid_esales.rate_limiter.excluded_routes) into a module’s
services.yaml. Symfony does not deep-merge parameter arrays, so two
modules each setting the same parameter would silently overwrite each other.
Put such overrides into the project-level
var/configuration/configurable_services.yaml, where a single source of
truth controls the value. See API Rate Limiting for a concrete example.
Accessing the Endpoint
Your API endpoint is now accessible at:
https://<shop-url>/api/products
Route Attributes
The #[Route] attribute provides powerful routing capabilities:
Path Parameters
Define dynamic path segments using {parameter} syntax:
Example
#[Route('/api/products/{id}', methods: ['GET'])]
public function getProduct(string $id): Response
{
$product = $this->productService->getProductById($id);
if (!$product) {
return new JsonResponse(['error' => 'Product not found'], 404);
}
return new JsonResponse($product);
}
URL: /api/products/abc123
Route Requirements
Add regex constraints to path parameters:
Example
#[Route('/api/products/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
public function getProduct(string $id): Response
{
// $id is guaranteed to be a 32-character hex string
}
Multiple Parameters
Combine multiple path parameters:
Example
#[Route('/api/categories/{categoryId}/products/{productId}', methods: ['GET'])]
public function getCategoryProduct(string $categoryId, string $productId): Response
{
// Access both parameters
}
Query Parameters
Access query parameters via the Request object:
Example
use Symfony\Component\HttpFoundation\Request;
#[Route('/api/products', methods: ['GET'])]
public function listProducts(Request $request): Response
{
$limit = (int) $request->query->get('limit', 10);
$offset = (int) $request->query->get('offset', 0);
$search = $request->query->get('search', '');
$products = $this->productService->getProducts($limit, $offset, $search);
return new JsonResponse([
'products' => $products,
'limit' => $limit,
'offset' => $offset,
]);
}
URL: /api/products?limit=20&offset=40&search=laptop
HTTP Methods
Restrict routes to specific HTTP methods:
Example
#[Route('/api/products', methods: ['POST'])]
public function createProduct(Request $request): Response
{
$data = json_decode($request->getContent(), true);
// Create product
return new JsonResponse(['id' => $newId], 201);
}
#[Route('/api/products/{id}', methods: ['PUT'])]
public function updateProduct(string $id, Request $request): Response
{
// Update product
return new JsonResponse(['updated' => true]);
}
#[Route('/api/products/{id}', methods: ['DELETE'])]
public function deleteProduct(string $id): Response
{
// Delete product
return new JsonResponse(null, 204);
}
Route Auto-Discovery
Routes are automatically discovered during container compilation through the RoutePass compiler pass.
How It Works
During container build, RoutePass scans all public services
For each public service, it checks for #[Route] attributes
All discovered routes are compiled into a routing table
The compiled routes are stored in the container parameter oxid.routes
At runtime, api.php uses this compiled routing table for fast matching
Important
Routes are only discovered from public services. If your API controller is not marked as public: true in services.yaml, its routes will not be available.
Performance Considerations
Route compilation happens during container build, not on every request. This means:
First request after code change may require container rebuild
Production environments benefit from persistent container cache
Development environments may need frequent cache clearing
Rate Limiting
All /api/* requests are rate-limited by default. See Rate Limiting
for configuration, response headers, and how to exclude specific routes.
Security and Authentication
Warning
As of version 7.5, API controllers do NOT provide automatic authentication or authorization.
You are responsible for implementing your own security layer.
Unlike traditional OXID controllers that integrate with shop authentication, API controllers are stateless by design. You must implement your own authentication mechanism.
Common Approaches
1. API Key Authentication
Example
readonly class SecureApiController
{
public function __construct(
private ApiKeyValidatorInterface $apiKeyValidator
) {
}
#[Route('/api/secure/data', methods: ['GET'])]
public function getData(Request $request): Response
{
$apiKey = $request->headers->get('X-API-Key');
if (!$this->apiKeyValidator->isValid($apiKey)) {
return new JsonResponse(['error' => 'Unauthorized'], 401);
}
// Proceed with authenticated request
}
}
2. JWT Token Authentication
OXID eShop provides a JWT Authenticator component for token-based authentication.
The component includes:
User authentication and JWT token generation
Symfony Security integration with
#[IsGranted]and#[CurrentUser]attributesRole-based access control
Ready-to-use login and profile endpoints
For complete documentation, see JWT Authentication.
3. OAuth 2.0
For more complex scenarios, consider implementing OAuth 2.0 using libraries like league/oauth2-server.
Best Practices
Dependency Injection
Always use constructor injection for dependencies:
// Good ✓
readonly class ProductApiController
{
public function __construct(
private ProductServiceInterface $productService,
private LoggerInterface $logger
) {
}
}
// Bad ✗ - Don't use ContainerFacade
class ProductApiController
{
public function listProducts(): Response
{
$service = ContainerFacade::get(ProductServiceInterface::class);
}
}
Troubleshooting
Route Not Found Error
Symptom: No routes found for "/api/products"
Causes:
Controller not registered as public service
Container cache is stale
Route attribute syntax error
Debug Mode
To get detailed error messages and stack traces, enable debug mode by setting the OXID_DEBUG_MODE environment variable:
# In .env file
OXID_DEBUG_MODE=1
# Or set directly
export OXID_DEBUG_MODE=1