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
<?php
declare(strict_types=1);
namespace Vendor\Module\Api\Data;
use Magento\Framework\Api\ExtensibleDataInterface;
/**
* Product entity interface
*/
interface ProductInterface extends ExtensibleDataInterface
{
const ENTITY_ID = 'entity_id';
const NAME = 'name';
const SKU = 'sku';
const PRICE = 'price';
/**
* @return int|null
*/
public function getId(): ?int;
/**
* @param int $id
* @return self
*/
public function setId(int $id): self;
/**
* @return string
*/
public function getName(): string;
/**
* @param string $name
* @return self
*/
public function setName(string $name): self;
// ... other getters/setters
}
PHPDoc is not optional here: the REST serializer reads @param/@return/@throws PHPDoc on Api/ interfaces via reflection to build the request/response schema — a type hint alone is not enough for the WebAPI framework to expose the field correctly.
Repository Implementation
php
<?php
declare(strict_types=1);
namespace Vendor\Module\Model;
use Vendor\Module\Api\Data\ProductInterface;
use Vendor\Module\Api\Data\ProductSearchResultsInterface;
use Vendor\Module\Api\ProductRepositoryInterface;
use Vendor\Module\Model\ResourceModel\Product as ProductResource;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Api\SearchResultsInterfaceFactory;
class ProductRepository implements ProductRepositoryInterface
{
public function __construct(
private readonly ProductFactory $productFactory,
private readonly ProductResource $resource,
private readonly ProductInterface $data,
private readonly SearchResultsInterfaceFactory $searchResultsFactory
) {}
public function save(ProductInterface $product): ProductInterface
{
$this->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
<!-- etc/webapi.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<!-- Route for single resource -->
<route method="GET" url="/V1/vendor/product/:id">
<service class="Vendor\Module\Api\ProductRepositoryInterface" method="getById"/>
<resources>
<resource ref="Vendor_Module::product_read"/>
</resources>
</route>
<!-- Route for collection -->
<route method="GET" url="/V1/vendor/products">
<service class="Vendor\Module\Api\ProductRepositoryInterface" method="getList"/>
<resources>
<resource ref="Vendor_Module::product_read"/>
</resources>
</route>
<!-- Route for create/update -->
<route method="POST" url="/V1/vendor/product">
<service class="Vendor\Module\Api\ProductRepositoryInterface" method="save"/>
<resources>
<resource ref="Vendor_Module::product_write"/>
</resources>
</route>
</routes>
ACL Configuration
xml
<!-- etc/acl.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:etc/acl.xsd">
<acl>
<resources>
<resource id="Magento_Backend::admin">
<resource id="Vendor_Module::product" title="Product" sortOrder="10">
<resource id="Vendor_Module::product_read" title="Read Product" sortOrder="10"/>
<resource id="Vendor_Module::product_write" title="Write Product" sortOrder="20"/>
<resource id="Vendor_Module::product_delete" title="Delete Product" sortOrder="30"/>
</resource>
</resource>
</resources>
</acl>
</config>
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
<?php
declare(strict_types=1);
namespace Vendor\Module\Model\Resolver;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Exception\GraphQlInputException;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
class ProductList implements ResolverInterface
{
public function __construct(
private readonly ProductRepositoryInterface $productRepository,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
private readonly FilterGroupBuilder $filterGroupBuilder
) {}
public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null)
{
if (!isset($args['pageSize'])) {
$args['pageSize'] = 20;
}
$searchCriteria = $this->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
<?php
declare(strict_types=1);
namespace Vendor\Module\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class SyncProductsCommand extends Command
{
protected $commandName = 'vendor:products:sync';
protected $commandDescription = 'Synchronize products from external source';
public function __construct(
private readonly ProductRepositoryInterface $productRepository,
private readonly ExternalApiClient $apiClient
) {
parent::__construct($this->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
<!-- etc/di.xml -->
<type name="Magento\Framework\Console\CommandList">
<arguments>
<argument name="commands" xsi:type="array">
<item name="vendor_products_sync"
xsi:type="object">Vendor\Module\Console\Command\SyncProductsCommand</item>
</argument>
</arguments>
</type>
<!-- Or use attribute (Magento 2.3+) -->
<!-- Vendor/Module/Console/Command/SyncCommand.php -->
<?php
declare(strict_types=1);
namespace Vendor\Module\Console\Command;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(
name: 'vendor:products:sync',
description: 'Synchronize products',
)]
class SyncCommand extends Command
{
// ...
}
If the command touches store-scoped config, catalog, or anything area-aware, set the area code explicitly before use: $this->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
<?php
declare(strict_types=1);
namespace Vendor\Module\Cron;