PrestaShop Admin API Endpoint Generator
This skill walks through creating a new endpoint in ps_apiresources, from discovery to generated code.
Step 1: Gather requirements
Ask the user (all in one message if not already specified):
- Entity name — e.g.
TaxRule,Warehouse(PascalCase, singular) - Operations needed — GET single, POST (create), PATCH (partial update), PUT (full update), DELETE, GET list (paginated). Also ask about bulk operations (e.g. bulk delete) or custom sub-resource actions (e.g. status toggle).
- PrestaShop core path — absolute path to the PS root, e.g.
/home/user/prestashop-90x. Needed to look up CQRS classes. Offer to skip this step if the user already knows the class names.
Step 2: Discover CQRS classes
Search the PS core for the entity's domain classes. The standard layout is:
{PS_ROOT}/src/Core/Domain/{Entity}/
Command/Add{Entity}Command.php
Command/Edit{Entity}Command.php
Command/Delete{Entity}Command.php
Command/BulkDelete{Entity}Command.php
Query/Get{Entity}ForEditing.php
Query/Get{Entity}ListForEditing.php ← may not exist; Grid is used for lists
QueryResult/{Entity}ForEditing.php ← shows what fields the query returns
Exception/{Entity}NotFoundException.php
Exception/{Entity}ConstraintException.php
Use Glob/Grep to find these files. Then read the QueryResult class (e.g. {Entity}ForEditing.php) — its constructor arguments and getters reveal the exact field names returned by the query. This is the ground truth for QUERY_MAPPING.
Also read the Command constructors to learn the parameter names needed for CQRSCommandMapping.
If some of these classes don't exist (e.g. no Add command), note it — only include operations that have backing CQRS classes.
Step 3: Gather field information
After reading the QueryResult, confirm with the user:
- Which fields should be exposed in the API (not all internal fields need to be public)
- Which fields are localized (arrays keyed by locale like
'en-US' => 'value') → need#[LocalizedValue] - Which localized fields are required in the default language on create → need
#[DefaultLanguage(groups: ['Create'], fieldName: '...')] - Which localized fields are optional on update →
#[DefaultLanguage(groups: ['Update'], fieldName: '...', allowNull: true)]
Step 4: Generate the ApiResource class
File location: src/ApiPlatform/Resources/{Entity}/{Entity}.php
Use the template below. Adapt based on the selected operations.
<?php
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Academic Free License version 3.0
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/AFL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0
*/
namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\{Entity};
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use PrestaShop\PrestaShop\Core\Domain\{Entity}\Command\Add{Entity}Command;
use PrestaShop\PrestaShop\Core\Domain\{Entity}\Command\Edit{Entity}Command;
use PrestaShop\PrestaShop\Core\Domain\{Entity}\Command\Delete{Entity}Command;
use PrestaShop\PrestaShop\Core\Domain\{Entity}\Exception\{Entity}ConstraintException;
use PrestaShop\PrestaShop\Core\Domain\{Entity}\Exception\{Entity}NotFoundException;
use PrestaShop\PrestaShop\Core\Domain\{Entity}\Query\Get{Entity}ForEditing;
use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate;
use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete;
use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet;
use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate;
use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new CQRSGet(
uriTemplate: '/{entities}/{entityId}',
CQRSQuery: Get{Entity}ForEditing::class,
scopes: ['{entity}_read'],
CQRSQueryMapping: self::QUERY_MAPPING,
),
new CQRSCreate(
uriTemplate: '/{entities}',
validationContext: ['groups' => ['Default', 'Create']],
CQRSCommand: Add{Entity}Command::class,
CQRSQuery: Get{Entity}ForEditing::class,
scopes: ['{entity}_write'],
CQRSQueryMapping: self::QUERY_MAPPING,
CQRSCommandMapping: self::CREATE_COMMAND_MAPPING,
),
new CQRSPartialUpdate(
uriTemplate: '/{entities}/{entityId}',
validationContext: ['groups' => ['Default', 'Update']],
CQRSCommand: Edit{Entity}Command::class,
CQRSQuery: Get{Entity}ForEditing::class,
scopes: ['{entity}_write'],
CQRSQueryMapping: self::QUERY_MAPPING,
CQRSCommandMapping: self::UPDATE_COMMAND_MAPPING,
),
new CQRSDelete(
uriTemplate: '/{entities}/{entityId}',
requirements: ['{entityId}' => '\d+'],
CQRSCommand: Delete{Entity}Command::class,
scopes: ['{entity}_write'],
),
],
exceptionToStatus: [
{Entity}ConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY,
{Entity}NotFoundException::class => Response::HTTP_NOT_FOUND,
],
)]
class {Entity}
{
#[ApiProperty(identifier: true)]
public int ${entityId};
// Add other properties here with appropriate types and constraints
public const QUERY_MAPPING = [
// '[sourceFieldFromQueryResult]' => '[apiFieldName]',
];
public const CREATE_COMMAND_MAPPING = [
// '[apiFieldName]' => '[commandConstructorParam]',
];
public const UPDATE_COMMAND_MAPPING = [
// '[apiFieldName]' => '[commandConstructorParam]',
];
}
Key rules while filling in the template
See references/conventions.md for the full ruleset. The most important ones:
- URI: plural, lowercase, kebab-case.
featureIdin URI, notid. E.g./tax-rules/{taxRuleId}. - Scopes:
{entity_snake_case}_readand{entity_snake_case}_write. Multi-word entities:tax_rule_read. - Boolean properties: no
isprefix. Use$enabled, not$isEnabled. - Localized properties: no "localized" prefix. Use
$names, not$localizedNames. Mark with#[LocalizedValue]. - Mapping:
QUERY_MAPPINGmaps[queryResultFieldName] => [apiFieldName].CREATE_COMMAND_MAPPINGmaps[apiFieldName] => [commandParamName]. When Create and Update commands share the same param names, use a singleCOMMAND_MAPPINGconstant. - One format for read and write: whatever a read operation returns for a piece of data, the write operation must accept in that exact shape — a response body must be copy-pasteable into the next request. Reconcile the command and query shapes with the mappings, never by exposing two properties for one concept. Beware the
@indexlimit: mapping renames and moves fields but cannot flatten or nest. Full rule, the exception for delta-style writes, and the normalizer escape hatch:CONTEXT.md→ "Read and write formats must match". - BO form parity: constraints must reproduce the rules of the entity's BO form type, including the ones the CQRS command doesn't enforce (non-empty collections, ranges, URL formats, PS constraints like
CleanHtml). Default values applied by the form when fields are untouched are declared with thedefaultValuesextra property on the create operation (PS ≥ 9.2 — never as resource property defaults, they don't reach the command; never on updates). Full rule:CONTEXT.md→ "Do" list. - Command-style operations: name the action in the URI, verb first (
/carriers/{carrierId}/set-tax-rule-group), add the segment toApiResourceUriTemplateRector::SKIPPED_KEYWORDSor Rector CI will pluralize it, host it in the resource it modifies, and return the updated entity via aCQRSQueryrather than a204. - Explicit OpenAPI summary when the method lies: the generated summary is derived from the HTTP method (POST ⇒ "Creates a … resource"), so any operation whose method doesn't match its semantics (e.g. an update forced to POST because it uploads a file) must declare
openapi: new OpenApiOperation(summary: …, description: …)on both operations sharing the method, so the doc doesn't show two identical create entries. - Forbidden: no custom normalizers, no custom processors, no Value Objects as properties (only scalar types and arrays).
- Strict typing: every property needs an explicit type.
List endpoints (PaginatedList)
List operations live in a separate class file named {Entity}List.php next to the main resource (e.g. src/ApiPlatform/Resources/Contact/ContactList.php, src/ApiPlatform/Resources/Attribute/AttributeGroupList.php). Use PaginatedList with the Grid's data factory service and a filtersClass. The URI is the plural base path (e.g. /contacts) without an ID. Field mapping is done via ApiResourceMapping.
Bulk operations
Bulk operations also live in a separate class file named Bulk{Entities}.php (or BulkDelete{Entities}.php / BulkUpdateStatus{Entities}.php when more specific) next to the main resource. Real examples in the repo:
src/ApiPlatform/Resources/Attribute/BulkAttributeGroups.phpsrc/ApiPlatform/Resources/Category/BulkDeleteCategories.phpsrc/ApiPlatform/Resources/Category/BulkUpdateStatusCategories.php
URI uses bulk- prefix + plural: /tax-rules/bulk-delete. The single public property is the array of IDs, named with the singular entity + "Ids" (e.g. $taxRuleIds, $attributeGroupIds), typed as array, and annotated with #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer']])] and #[Assert\NotBlank].
Step 5: Generate the integration test
File location: tests/Integration/ApiPlatform/{Entity}EndpointTest.php
<?php
/**
* [AFL 3.0 license header — same as ApiResource file]
*/
declare(strict_types=1);
namespace PsApiResourcesTest\Integration\ApiPlatform;
use Symfony\Component\HttpFoundation\Response;
use Tests\Resources\DatabaseDump;
use Tests\Resources\Resetter\LanguageResetter;
class {Entity}EndpointTest extends ApiTestCase
{
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
LanguageResetter::resetLanguages();
self::addLanguageByLocale('fr-FR');
self::resetTables();
self::createApiClient(['{entity}_read', '{entity}_write']);
}
public static function tearDownAfterClass(): void
{
parent::tearDownAfterClass();
LanguageResetter::resetLanguages();
self::resetTables();
}
protected static function resetTables(): void
{
DatabaseDump::restoreTables([
'{db_table}', // add all related tables
'{db_table}_lang', // if entity has localized fields
'{db_table}_shop', // if entity has shop association
]);
}
public static function getProtectedEndpoints(): iterable
{
// List every endpoint that should require authentication
yield 'get endpoint' => ['GET', '/{entities}/1'];
yield 'create endpoint' => ['POST', '/{entities}'];
yield 'patch endpoint' => ['PATCH', '/{entities}/1'];
yield 'delete endpoint' => ['DELETE', '/{entities}/1'];
yield 'list endpoint' => ['GET', '/{entities}'];
}
public function testAdd{Entity}(): int
{
$postData = [
// all required fields with valid values
// for localized fields: ['en-US' => 'value', 'fr-FR' => 'valeur']
];
$response = $this->createItem('/{entities}', $postData, ['{entity}_write']);
$this->assertArrayHasKey('{entityId}', $response);
return $response['{entityId}'];
}
/** @depends testAdd{Entity} */
public function testGet{Entity}(int ${entityId}): int
{
$response = $this->getItem('/{entities}/' . ${entityId}, ['{entity}_read']);
$this->assertEquals(${entityId}, $response['{entityId}']);
// assert all exposed fields are present and correct
return ${entityId};
}
/** @depends testGet{Entity} */
public function testPartialUpdate{Entity}(int ${entityId}): int
{
$patchData = [
// fields to update
];
$updated = $this->partialUpdateItem('/{entities}/' . ${entityId}, $patchData, ['{entity}_write']);
// assert updated fields match
// Verify the GET also reflects the changes
$fetched = $this->getItem('/{entities}/' . ${entityId}, ['{entity}_read']);
// assert same fields
return ${entityId};
}
/** @depends testPartialUpdate{Entity} */
public function testDelete{Entity}(int ${entityId}): void
{
$this->deleteItem('/{entities}/' . ${entityId}, ['{entity}_write']);
$this->getItem('/{entities}/' . ${entityId}, ['{entity}_read'], Response::HTTP_NOT_FOUND);
}
public function testInvalid{Entity}(): void
{
$invalidData = [
// invalid values to trigger validation errors
];
$response = $this->createItem(
'/{entities}',
$invalidData,
['{entity}_write'],
Response::HTTP_UNPROCESSABLE_ENTITY
);
$this->assertValidationErrors([
['propertyPath' => 'fieldName', 'message' => 'expected error message'],
], $response);
}
}
Test rules
- Tests must assert complete response data — all fields, not just the identifier.
- Chain tests using
@dependsso each test builds on the created entity. - Always include a
testInvalid*test that covers validation constraints. - Include
'skip_null_values' => falsein assertions when usingassertEqualson the full response array. - Drop
LanguageResetter::resetLanguages()from setUp/tearDown if the entity has no localized fields.
Step 6: Write the files
Create both files at their correct paths. Then tell the user:
- Where the files were written
- How to register the resource — if the ApiResource isn't auto-discovered, a service definition may be needed (usually not required for standard resources)
- How to run the tests:
composer setup-local-tests # first time only composer run-module-tests - What to double-check: mappings between CQRS fields and API fields — these are the most common source of errors. Ask the user to run the endpoint manually via Swagger UI to verify.
Common pitfalls to flag
- If the
Add*Commandtakes its ID from anEntityIdvalue object in the result (not a raw int), theCQRSCreateblock may need to fetch the result via a separateCQRSQueryafter creation. - If a command and its edit counterpart share the same constructor signature, use one
COMMAND_MAPPINGconstant. - For entities without shop association, omit
shopIdsand its mapping. #[DefaultLanguage]needs thefieldNameargument set to the API field name (e.g.fieldName: 'names'), not the internal query field name.
Reference files
references/conventions.md— full naming and structural conventions../../../CONTEXT.md(repo root) — module-wide AI context: purpose, architecture, Do/Don't, canonical examples. This skill must stay aligned with it.