--- name: magento2-backend-dev description: | This skill should be used when the user asks to "create an API endpoint", "build a REST API", "add a GraphQL resolver", "create a CLI command", "add a cron job", "set up a message queue", "implement a web API", "add a SOAP service", or "create a data provider". Covers Magento 2 backend development: REST/SOAP/GraphQL APIs, CLI commands, and cron jobs. DEPENDENT on magento2-dev-core for security and architecture patterns. compatibility: claude, codex, opencode, copilot depends: [magento2-dev-core] metadata: audience: backend developers workflow: magento --- # Magento 2 Backend Developer This skill covers API development (REST, SOAP, GraphQL), CLI commands, cron jobs, and message queues. ## Related Skills **REQUIRED BACKGROUND:** Load `magento2-dev-core` first — it defines the DI, repository, and security patterns (constructor injection, service contracts, escaping, discouraged functions) this skill assumes without repeating. Pairs with `magento2-security-scan` when the API/resolver you're building touches authentication, ACL, or user input, and with `magento2-performance-audit` for queue/consumer and N+1 concerns once the endpoint is built. In a Govard environment, use `govard-magento` for the CLI/container side (`bin/magento`, cache, indexers). ## REST API ### Service Contract Structure ``` Vendor/Module/ ├── Api/ │ ├── ProductRepositoryInterface.php # Declaration │ └── Data/ │ └── ProductInterface.php # Data entity └── Model/ └── ProductRepository.php # Implementation ``` ### Data Interface ```php resource->save($product); return $product; } public function getById(int $id): ProductInterface { $product = $this->productFactory->create(); $this->resource->load($product, $id); if (!$product->getId()) { throw new \Magento\Framework\Exception\NoSuchEntityException( __('Product with ID %1 does not exist', $id) ); } return $product; } public function get(SearchCriteriaInterface $searchCriteria): ProductSearchResultsInterface { $searchResults = $this->searchResultsFactory->create(); $searchResults->setSearchCriteria($searchCriteria); $collection = $this->productCollection->create(); $this->applySearchCriteria($collection, $searchCriteria); $searchResults->setItems($collection->getItems()); $searchResults->setTotalCount($collection->getSize()); return $searchResults; } } ``` ### WebAPI Configuration ```xml ``` ### ACL Configuration ```xml ``` ## GraphQL ### Schema Definition ```graphql # etc/schema.graphqls type Query { products(filter: ProductFilterInput, pageSize: Int = 20, currentPage: Int = 1): Products @doc(description: "Get products list") @resolver(class: "Vendor\\Module\\Model\\Resolver\\ProductList") @cache(cacheable: false) } type Mutation { createProduct(input: ProductInput!): Product @doc(description: "Create a new product") @resolver(class: "Vendor\\Module\\Model\\Resolver\\CreateProduct") @cache(cacheable: false) } input ProductFilterInput { entity_id: FilterTypeInput name: FilterTypeInput sku: FilterTypeInput price: FilterTypeInput } type Product { entity_id: Int name: String sku: String price: Float } input ProductInput { name: String! sku: String! price: Float! } ``` ### Resolver Implementation ```php searchCriteriaBuilder ->setPageSize($args['pageSize']) ->setCurrentPage($args['currentPage'] ?? 1) ->create(); $searchResults = $this->productRepository->get($searchCriteria); return [ 'total_count' => $searchResults->getTotalCount(), 'items' => $this->convertProducts($searchResults->getItems()) ]; } private function convertProducts(array $products): array { return array_map(function ($product) { return [ 'entity_id' => $product->getId(), 'name' => $product->getName(), 'sku' => $product->getSku(), 'price' => $product->getPrice() ]; }, $products); } } ``` For cacheable GraphQL types, implement `IdentityInterface` on the resolver (or a dedicated identity provider) so Magento can tag the response for full-page cache invalidation — without it, `@cache(cacheable: true)` has nothing to key on and the type is effectively never cached correctly. ## CLI Commands ### Command Class ```php commandName); } protected function configure(): void { $this->setDescription($this->commandDescription); $this->addOption( 'dry-run', 'd', InputOption::VALUE_NONE, 'Run without making changes' ); $this->addOption( 'limit', 'l', InputOption::VALUE_REQUIRED, 'Limit number of products', 100 ); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $io->title('Product Synchronization'); $limit = (int) $input->getOption('limit'); $dryRun = $input->getOption('dry-run'); if ($dryRun) { $io->note('Running in dry-run mode'); } try { $externalProducts = $this->apiClient->fetchProducts($limit); $io->progressStart(count($externalProducts)); foreach ($externalProducts as $externalProduct) { if (!$dryRun) { $this->syncProduct($externalProduct); } $io->progressAdvance(); } $io->progressFinish(); $io->success(sprintf('Synchronized %d products', count($externalProducts))); return Command::SUCCESS; } catch (\Exception $e) { $io->error('Synchronization failed: ' . $e->getMessage()); return Command::FAILURE; } } } ``` ### Register Command ```xml Vendor\Module\Console\Command\SyncProductsCommand state->setAreaCode(Area::AREA_ADMINHTML)` (or `AREA_FRONTEND`/`AREA_GLOBAL`) — a bare CLI command defaults to no area, and area-dependent services throw a `LocalizedException` otherwise. ## Cron Jobs ### Cron Class ```php logger->info('Running expired product cleanup'); try { $expiredProducts = $this->findExpiredProducts(); foreach ($expiredProducts as $product) { $product->setStatus(Status::STATUS_DISABLED); $this->productRepository->save($product); } $this->logger->info(sprintf('Cleaned up %d expired products', count($expiredProducts))); } catch (\Exception $e) { $this->logger->error('Cleanup failed: ' . $e->getMessage()); } } } ``` ### Cron Configuration ```xml 0 2 * * * */5 * * * * ``` ### Cron Groups (for large scale) ```xml 1 4 2 10 1440 60 1 ``` ## Message Queue Not every project needs all four queue XML files — `communication.xml` (topic schema) is the one that's always required. Add `queue_topology.xml`, `queue_publisher.xml`, `queue_consumer.xml` only for what the use case actually needs (e.g. just `queue_publisher.xml` when publishing to a queue a third party already owns). ### Publisher Configuration ```xml ``` ### Queue Consumer ```php handler->process($data); } } ``` ### Message Class ```php ``` ## Pitfalls recap - The `resource ref` in `webapi.xml` must match an actual `id` declared in `acl.xml` — a typo here fails silently with a 403, not a config error. - Always throw `NoSuchEntityException` (not return `null`) when a repository can't find an entity — the WebAPI framework maps it to a proper 404. - A configured consumer (`queue.xml`) does nothing on its own — it must actually be running as a process via cron or a supervisor (`bin/magento queue:consumers:start`), or messages just pile up in the `queue_message` tables. See `magento2-performance-audit`. - GraphQL resolvers get no automatic ACL check — validate the customer/admin context explicitly inside `resolve()` if the field exposes anything sensitive. `cron_schedule` rows move through `pending` → `running` → `success`/`error`/`missed`. Never `TRUNCATE cron_schedule` to "fix" a stuck cron — query it (`WHERE status IN ('error','missed')`) to diagnose the actual cause instead, since truncating destroys the run history you'd need to find it. ## Verification ```bash # Test CLI command bin/magento vendor:products:sync --dry-run --limit=10 # List registered commands bin/magento list | grep vendor # Run cron manually bin/magento cron:run --group=custom # Check queue consumers bin/magento queue:consumers:list # Start message queue consumer bin/magento queue:consumers:start vendor.product.update.consumer # Test REST API curl -X GET "http://localhost/V1/vendor/product/1" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" # Clear API cache bin/magento cache:clean config ```