Extending & Deployment v26.7+
Adding a New API Resource
Resources are declared with one attribute: #[\Maho\Config\ApiResource], a drop-in subclass of \ApiPlatform\Metadata\ApiResource that adds Maho's permission-registry metadata alongside API Platform's HTTP/GraphQL configuration. Use it instead of \ApiPlatform\Metadata\ApiResource on every DTO.
namespace MyVendor\MyModule\Api;
use Maho\Config\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
#[ApiResource(
shortName: 'WidgetType',
operations: [
new Get(uriTemplate: '/widget-types/{id}', security: 'true'),
new GetCollection(uriTemplate: '/widget-types', security: 'true'),
],
mahoPublicRead: true, // optional override
mahoSection: 'Widgets', // optional override
mahoOperations: ['read' => 'View Widget Types'], // optional override
)]
final class WidgetType { /* ApiProperty fields */ }
After adding or modifying the attribute, run composer dump-autoload. The compiler walks every class carrying #[Maho\Config\ApiResource] (anywhere in app/code/ or installed packages) and emits vendor/composer/maho_api_permissions.php, which Maho\ApiPlatform\Security\ApiPermissionRegistry reads to populate the admin role editor UI and the list of valid resource/operation permission ids.
Authorization itself does not go through the registry. Each operation declares the permission it requires literally in its own security: expression (e.g. security: "is_granted('orders/read')"); API Platform's access checker evaluates that expression for both REST and GraphQL and routes the resource/operation attribute to Maho\ApiPlatform\Security\ApiUserVoter. So the permission you grant in the role editor is simply the resource/operation string an operation names in its security:.
Auto-derivation
Most permission-registry fields are derived from the API Platform metadata on the same attribute, set them explicitly only when defaults are wrong:
| Maho field | Derived from when omitted |
|---|---|
mahoId | shortName → kebab-case + plural (Cart → carts, CmsPage → cms-pages) |
mahoLabel | Title-cased mahoId (cms-pages → CMS Pages; ≤3-char segments are upper-cased as acronyms) |
mahoSection | Module segment of the namespace (Mage\Catalog\Api\Foo → 'Catalog') |
mahoOperations | One entry per operation type present in operations: [...]. Default labels: read/create/write/delete → View/Create/Update/Delete |
mahoPublicRead | true when every read operation has security: 'true'. Override explicitly only if your read security expression doesn't use that literal form |
mahoCustomerScoped | No equivalent, must be explicit for resources bound to a logged-in customer (carts, wishlists, addresses, etc.) |
For customer-scoped resources, the parent's description: doubles as admin-UI prose, the compiler reads it via getDescription() and surfaces it in the role editor. Write it as action-oriented prose ("View cart, add/remove items, apply coupons, set shipping & payment") so it's useful for both API docs and admins.
Forward-looking resources (no DTO yet)
Permissions for endpoints you plan to build but haven't shipped go on a stub class with operations: [] (explicit empty, not null, which would trigger API Platform's CRUD defaults). API Platform sees the resource but registers zero routes; only the maho fields populate the permission registry. Delete the stub when the real DTO ships.
namespace MyVendor\MyModule\PermissionStubs;
use Maho\Config\ApiResource;
#[ApiResource(
operations: [],
mahoId: 'widget-attributes',
mahoLabel: 'Widget Attributes',
mahoSection: 'Widgets',
mahoOperations: ['read' => 'View', 'write' => 'Edit'],
)]
final class WidgetAttributes {}
Multiple #[ApiResource] on one class
The attribute is repeatable, a single class can carry several declarations with different uriTemplate / operations sets that share one permission identity (the Cms Media DTO uses this pattern for /media and /media/{path}). Just give each attribute the same mahoId and the compiler unions their segments and GraphQL fields under one registry entry.
Extending the API (Third-Party Modules)
All API resources extend \Maho\ApiPlatform\Resource, which provides an extensions field, an open array where modules can inject additional data without modifying core API files. The base class also provides a toArray() method for serializing DTOs (used by GraphQL handlers).
Providers build DTOs via toDto($model) (the abstract method on the Provider base class). A handful of providers (Order, Category, Address, Customer, Product, Cart) also expose a public mapToDto() method with domain-specific extra arguments, used directly from GraphQL handlers and custom processors when they need a consistent representation including extensions.
How It Works
Every resource DTO (Product, Category, Cart, Order, etc.) dispatches a Maho event after building the response object. Your module observes the event and appends data to $dto->extensions. These events fire for both REST and GraphQL, the GraphQL handlers use the same Provider/Mapper DTO-building methods as REST, ensuring consistent behavior across both APIs.
Event area: api
The API Platform loads a dedicated api event area (Mage_Core_Model_App_Area::AREA_API), similar to frontend and adminhtml. Observers registered under <api><events> in config.xml only load when the API is running, they won't fire on regular frontend, admin, or cron requests.
Available Events
| Event | Dispatched In | Observer Parameters |
|---|---|---|
api_product_dto_build | ProductProvider | product (model), for_listing (bool), dto |
api_category_dto_build | CategoryProvider | category (model), dto |
api_store_config_dto_build | StoreConfigProvider | dto |
api_order_dto_build | OrderProvider | order (model), dto |
api_order_item_dto_build | OrderProvider | item (model), dto |
api_customer_dto_build | CustomerProvider | customer (model), dto |
api_cart_dto_build | CartMapper | quote (model), dto |
api_cart_item_dto_build | CartMapper | item (model), dto |
api_wishlist_item_dto_build | WishlistProvider | dto |
api_captcha_config | ApiPlatform Helper | config (DataObject) |
api_verify_captcha | ApiPlatform Helper | result (DataObject), data (array) |
Quick Example: Simple Bundles Module
A module that adds bundle component data to products and cart items.
1. Register the observer in your module's config.xml:
<config>
<api>
<events>
<api_product_dto_build>
<observers>
<simple_bundles>
<class>Vendor_SimpleBundles_Model_Api_Observer</class>
<method>addBundleToProduct</method>
</simple_bundles>
</observers>
</api_product_dto_build>
<api_cart_item_dto_build>
<observers>
<simple_bundles>
<class>Vendor_SimpleBundles_Model_Api_Observer</class>
<method>addBundleToCartItem</method>
</simple_bundles>
</observers>
</api_cart_item_dto_build>
</events>
</api>
</config>
2. Write the observer:
class Vendor_SimpleBundles_Model_Api_Observer
{
public function addBundleToProduct(\Maho\Event\Observer $observer): void
{
$product = $observer->getEvent()->getProduct();
$dto = $observer->getEvent()->getDto();
// Only add bundle data on detail view, not listings
if ($observer->getEvent()->getForListing()) {
return;
}
$bundleItems = Mage::getModel('simplebundles/item')
->getCollection()
->addProductFilter($product->getId());
if ($bundleItems->count() === 0) {
return;
}
$dto->extensions['simpleBundle'] = [
'items' => array_map(fn ($item) => [
'sku' => $item->getSku(),
'name' => $item->getName(),
'qty' => (int) $item->getQty(),
], $bundleItems->getItems()),
];
}
public function addBundleToCartItem(\Maho\Event\Observer $observer): void
{
$quoteItem = $observer->getEvent()->getItem();
$dto = $observer->getEvent()->getDto();
$bundleData = $quoteItem->getOptionByCode('simple_bundle_data');
if (!$bundleData) {
return;
}
$dto->extensions['simpleBundle'] = json_decode($bundleData->getValue(), true);
}
}
3. API response now includes the extension data:
{
"id": 42,
"sku": "OUTFIT-SUMMER",
"name": "Summer Festival Outfit",
"price": 189.95,
"extensions": {
"simpleBundle": {
"items": [
{"sku": "DRESS-FLR-M", "name": "Floral Midi Dress", "qty": 1},
{"sku": "HAT-STRAW", "name": "Wide Brim Straw Hat", "qty": 1},
{"sku": "SANDAL-TAN-8", "name": "Tan Leather Sandals", "qty": 1}
]
}
}
}
Guidelines
- Namespace your data, use a unique key in
extensions(e.g.simpleBundle, notitems) - Keep it lightweight, avoid loading heavy collections in listing mode (check
for_listing) - Return serializable data, arrays and scalars only, no objects
- Extensions are read-only, the
extensionsfield is populated during read operations; for write operations, use standard Maho model events or custom API processors
Deployment Notes
Filesystem permissions
The Symfony kernel writes its compiled container, route table, and metadata cache to var/cache/api_platform/{env}/ (where {env} is prod or dev). The directory must be writable by the PHP-FPM/Apache user that handles /api/* requests. On a fresh deploy:
Cache pre-warm
The first request after a deploy pays a one-time container compilation cost (~hundreds of ms). To keep that out of the critical path, warm the cache during deployment:
# As the web user, after `composer install` and before flipping the load balancer:
php -r 'require "vendor/autoload.php"; Mage::app(); $k = new Maho\ApiPlatform\Kernel("prod", false); $k->boot();'
Run this whenever module API resources change (new/modified #[ApiResource] classes), in addition to composer dump-autoload which refreshes the permission registry compiled file.
Cache invalidation
The container cache is keyed by class file mtimes; a normal deploy that overwrites files invalidates it automatically. If you ever need to force a rebuild manually, delete var/cache/api_platform/{env}/, the next request will recompile.