skills/pimcore-studio-backend-controller/SKILL.md
Pimcore Studio controller patterns — one-action-per-controller, OpenAPI attributes, route configuration, parameter binding with DTOs, void/upload endpoints, and route priority rules
npx skillsauth add pimcore/skills pimcore-studio-backend-controllerInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
The StudioBackendBundle uses a layered architecture: Controllers (HTTP only) -> Services (business logic) -> Hydrators (DTO creation). Events provide extension points. All APIs are OpenAPI-documented.
Controllers handle HTTP concerns ONLY: route definition, OpenAPI docs, parameter binding, delegate to a service, return a response. No business logic, no permission checks, no vendor calls.
final class GetController extends AbstractApiController
{
private const string ROUTE = '/config/{name}';
public function __construct(
SerializerInterface $serializer,
private readonly ConfigurationServiceInterface $configurationService
) {
parent::__construct($serializer);
}
#[Route(path: self::ROUTE, name: 'pimcore_studio_api_...', methods: ['GET'])]
#[Get(
path: Prefix::BUNDLE . self::ROUTE,
operationId: 'bundle_data_importer_...',
description: 'bundle_data_importer_..._description',
summary: 'bundle_data_importer_..._summary',
tags: [Tags::DataImporter->value]
)]
// ... OpenAPI parameter/response attributes ...
#[IsGranted(PermissionConstants::PLUGIN_DATA_IMPORTER_CONFIG)]
#[DefaultResponses([HttpResponseCodes::UNAUTHORIZED, ...])]
public function actionMethod(string $name): JsonResponse
{
return $this->jsonResponse($this->configurationService->doSomething($name));
}
}
final class (not final readonly class) — extends AbstractApiController with mutable state.@internal PHPDoc on every controller class.private const string ROUTE — always typed.Prefix::BUNDLE . self::ROUTE (bundle defines its own prefix).Tags enum (Tags::DataImporter->value).bundle_{bundle_name}_{domain}_{action}[_suffix].#[IsGranted]: Always present on every action.#[DefaultResponses]: Always includes at least HttpResponseCodes::UNAUTHORIZED.JsonResponse via $this->jsonResponse(...) for data responses; Response via new Response() for void/upload operations.Controller\Studio\{Domain}\{Action}Controller
Config/GetController, Config/SaveController, DataType/LoadClassAttributesControllerGetController not GetConfigurationController).Config/, ClassificationStore/, DataType/, Utility/.When migrating a legacy controller with multiple actions, split each action into its own controller class under a subdirectory:
# Legacy (one controller, many actions):
Admin/JobRunController.php -> list(), getRunningJobs(), rerun(), cancel()
# Studio (one controller per action, organized in subdirectory):
Controller/Studio/JobRun/JobRunsController.php -> GET /job-runs
Controller/Studio/JobRun/JobRunsRunningController.php -> GET /job-runs/running
Controller/Studio/JobRun/RerunController.php -> POST /job-runs/{id}/rerun
Controller/Studio/JobRun/CancelController.php -> POST /job-runs/{id}/cancel
Name each controller after the action it performs (or the resource it returns), not the legacy method name. Group related controllers in a subdirectory named after the domain.
When a parent route uses a wildcard parameter (e.g., /{name}) and sibling routes have literal path segments (e.g., /types, /environments), the wildcard can capture the literal segment before the specific route is matched. Fix this by adding priority: 10 to the #[Route] attribute on static-path controllers:
// This route would be captured by /{name} without priority
#[Route(path: self::ROUTE, name: 'pimcore_studio_api_copilot_configuration_types', methods: ['GET'], priority: 10)]
Apply this to every controller whose literal path segment could conflict with a wildcard in a sibling controller.
| Source | Attribute | Controller Param |
|--------|----------|-----------------|
| Path params | #[IdParameter(...)] | Native typed param: string $name |
| JSON body | #[ReferenceRequestBody(...)] | #[MapRequestPayload] SomeParameters $params |
| Query string | Per-param attributes + #[MapQueryString] | #[MapQueryString] SomeParameters $params = new SomeParameters() |
| File upload | #[MultipartFormDataRequestBody(...)] | Request $request + manual $request->files->get('file') |
Controllers MUST NEVER access raw Request objects to extract query parameters or request body data. Always use dedicated DTO classes with Symfony's #[MapQueryString] or #[MapRequestPayload] attributes instead.
$request->query->get() / $request->request->get() bypasses type safety, has no validation, and scatters parameter parsing logic across controllers.#[MapQueryString]Every GET endpoint that accepts query parameters MUST define a final readonly class DTO and bind it via #[MapQueryString]:
// DTO
final readonly class PaginationParameters
{
public function __construct(
private int $page = 1,
private int $pageSize = 100,
) {
}
public function getPage(): int
{
return max(1, $this->page);
}
public function getPageSize(): int
{
return max(1, $this->pageSize);
}
}
// Controller
public function listItems(
#[MapQueryString] PaginationParameters $parameters = new PaginationParameters(),
): JsonResponse {
return $this->jsonResponse(
$this->service->listItems(
$parameters->getPage(),
$parameters->getPageSize(),
)
);
}
Key rules for query string DTOs:
final readonly class with @internal.max(1, $this->page) to enforce minimum values).$parameters = new SomeParameters().#[IntParameter], #[TextFieldParameter], #[BoolParameter]) stay on the controller method for documentation, but actual value extraction comes from the DTO.PaginationParameters.#[MapRequestPayload]Every POST/PUT endpoint that accepts a JSON body MUST define a DTO and bind it via #[MapRequestPayload]:
public function createItem(
#[MapRequestPayload] CreateItemParameters $parameters,
): JsonResponse {
return $this->jsonResponse(
$this->service->createItem($parameters)
);
}
Request Is AcceptableRaw Request $request is ONLY acceptable for file uploads ($request->files->get('file')), because Symfony's MapRequestPayload does not handle multipart file data. If you encounter any other situation where you believe a DTO cannot be used, STOP and ask the user for a decision before implementing with raw Request access.
// BAD -- raw Request access for query params
public function listItems(Request $request): JsonResponse
{
$page = (int) $request->query->get('page', 1);
$pageSize = (int) $request->query->get('pageSize', 100);
return $this->jsonResponse($this->service->listItems($page, $pageSize));
}
// GOOD -- DTO with MapQueryString
public function listItems(
#[MapQueryString] PaginationParameters $parameters = new PaginationParameters(),
): JsonResponse {
return $this->jsonResponse(
$this->service->listItems($parameters->getPage(), $parameters->getPageSize())
);
}
Endpoints that perform an action but return no data (uploads, copy-preview, cancel-execution):
public function upload(Request $request): Response
{
$this->service->uploadSomething(...);
return new Response();
}
new Response() (Symfony HttpFoundation\Response), not JsonResponse.void.#[SuccessResponse(description: '...')] with no content parameter:
#[SuccessResponse(description: 'bundle_data_importer_upload_preview_success_description')]
Data endpoints in contrast include content: new JsonContent(ref: SomeResponse::class).// Controller
public function upload(Request $request): Response
{
$file = $request->files->get('file');
if (!$file instanceof UploadedFile) {
throw new EnvironmentException('Invalid file found in the request');
}
$this->previewDataService->uploadPreviewData($name, $file);
return new Response();
}
OpenAPI for file uploads uses MultipartFormDataRequestBody with a second argument for required fields:
#[MultipartFormDataRequestBody(
[
new Property(
property: 'file',
description: 'Preview data file to upload',
type: 'string',
format: 'binary'
),
],
['file']
)]
Service-side file upload validation:
private const int MAX_FILE_SIZE = 10485760; // 10MB
public function uploadPreviewData(string $name, UploadedFile $file): void
{
try {
// ... permission check ...
if ($file->getSize() === 0) {
throw new EnvironmentException('Uploaded file is empty');
}
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new MaxFileSizeExceededException(self::MAX_FILE_SIZE);
}
// ... process file ...
} finally {
@unlink($file->getPathname());
}
}
DefaultResponses for upload endpoints includes HttpResponseCodes::MAX_FILE_SIZE_EXCEEDED.tools
UX and UI design conventions for Pimcore Studio - layout, spacing, action labels, writing style, and design principles for consistent extensions
tools
Widget system in Pimcore Studio UI - registering widgets, opening them in layout areas, WidgetManagerTabConfig, and connecting widgets to navigation
tools
How bundles consume the Pimcore Studio UI SDK - plugins, modules, DI, registries, and imports
development
TypeScript coding standards and best practices for Pimcore Studio UI - type safety, null checks, and code quality