Custom data and extensions
Most stores have data that isn't a standard Magento field: a tab added by an extension, a grid your agency built, a table of per-product settings. This page is for developers who want that data to be staged, previewed, deployed, rolled back and tracked in revisions like everything else.
What works without any code
- Custom category and product attributes. Any EAV attribute on the category or product form is picked up automatically, with its scope (global, website or store view), "Use Default Value" and its type: text, dropdown, multiselect, date, price and so on. This includes attributes added by third-party extensions.
- Attributes in custom attribute sets and groups. Where an attribute sits on the form doesn't matter.
You only need to write code when the data lives outside the entity's attributes, typically in a table of its own that an extension saves when the form is saved.
How the module is organised
The module describes each kind of content (CMS page, CMS block, category, product) with an adapter. An adapter is made of section handlers, each of which owns a group of fields on the form: the scalar attributes, the image gallery, customizable options, tier prices and so on.
Every MageDrop feature works through the sections, so a section you add gets all of them:
| When | MageDrop calls | Your section |
|---|---|---|
| Save & Stage | extract() and current() | Says what the form would save and what is live. Only differences are staged. |
| Preview | overlay() | Puts the staged value on the frontend model, in memory only. |
| Load changes into the form | toFormData() | Shows staged values in the admin form. |
| Deploy and rollback | apply() | Writes the value. The previous value is captured first, so the rollback just applies it. |
| Revisions | current() | Included in every snapshot, and restorable. |
The interfaces live in MageDrop\Magento2\Model\Entity\Section. Values are passed as MageDrop\Magento2\Model\Entity\Value envelopes: Value::text() for scalars, Value::json() for arrays and rows, Value::image() for media paths, and Value::inherit() for "Use Default Value".
Example: a "Delivery promises" tab
Say an extension, Acme_DeliveryPromise, adds a Delivery promises tab to the product form: a dynamic-rows grid of regions and delivery times, saved to its own acme_delivery_promise table by an observer on product save. On the storefront, a block on the product page lists the promises.
The merchant wants to change the promises for the Christmas period and put them back in January, so the tab needs to be part of releases.
1. Create a small bridge module
Keep the integration in its own module so the extension doesn't depend on MageDrop, and MageDrop doesn't depend on the extension. In etc/module.xml, load it after both:
<module name="Acme_DeliveryPromiseMageDrop">
<sequence>
<module name="MageDrop_Magento2"/>
<module name="Acme_DeliveryPromise"/>
</sequence>
</module>
2. Write the section handler
The handler owns one field, acme_delivery_promises, holding all the rows as one JSON value. Staging a grid as a whole keeps it simple: the release holds exactly the rows the merchant saw, and a rollback puts back exactly the rows that were live.
<?php
declare(strict_types=1);
namespace Acme\DeliveryPromiseMageDrop\Model;
use Acme\DeliveryPromise\Model\PromiseRepository;
use MageDrop\Magento2\Model\Entity\Section\AfterSaveInterface;
use MageDrop\Magento2\Model\Entity\Section\SectionHandlerInterface;
use MageDrop\Magento2\Model\Entity\Value;
use Magento\Framework\DataObject;
class DeliveryPromisesSection implements SectionHandlerInterface, AfterSaveInterface
{
public const FIELD = 'acme_delivery_promises';
private const PENDING = '_acme_delivery_promises_pending';
public function __construct(private PromiseRepository $promises)
{
}
/** What the admin form would save. Only when the tab was on the form. */
public function extract(array $post, DataObject $entity, int $storeId): array
{
$raw = $post['_post']['product'] ?? [];
if (!array_key_exists(self::FIELD, $raw)) {
return [];
}
return [self::FIELD => Value::json($this->normalise((array) $raw[self::FIELD]))];
}
/** What is live. */
public function current(DataObject $entity, int $storeId, ?array $fields = null): array
{
if ($fields !== null && !in_array(self::FIELD, $fields, true)) {
return [];
}
$rows = $this->promises->getForProduct((int) $entity->getId());
return [self::FIELD => Value::json($this->normalise($rows))];
}
public function handles(string $field, DataObject $entity): bool
{
return $field === self::FIELD;
}
/** Promises are the same on every store view. */
public function isScopable(DataObject $entity, string $field): bool
{
return false;
}
public function isOverridden(DataObject $entity, string $field, int $storeId): bool
{
return true; // global data has nothing to inherit from
}
/** Deploy and rollback: remember the rows; they are written once the product has saved. */
public function apply(DataObject $entity, array $values, int $storeId): void
{
if (isset($values[self::FIELD])) {
$entity->setData(self::PENDING, $this->normalise((array) $values[self::FIELD]->value));
}
}
public function afterSave(DataObject $entity, int $storeId): void
{
$rows = $entity->getData(self::PENDING);
if ($rows !== null) {
$this->promises->replaceForProduct((int) $entity->getId(), $rows);
$entity->unsetData(self::PENDING);
}
}
/** Preview: in memory only. The storefront block reads this before the table. */
public function overlay(DataObject $entity, array $values): void
{
if (isset($values[self::FIELD])) {
$entity->setData(self::FIELD, $this->normalise((array) $values[self::FIELD]->value));
}
}
/** Show staged rows in the admin form ("Load changes"). */
public function toFormData(array $data, array $values): array
{
if (isset($values[self::FIELD])) {
$rows = [];
foreach ($this->normalise((array) $values[self::FIELD]->value) as $i => $row) {
$rows[] = $row + ['record_id' => $i];
}
$data[self::FIELD] = $rows;
}
return $data;
}
/** Same shape and order whatever the source, so unchanged grids compare equal. */
private function normalise(array $rows): array
{
$out = [];
foreach ($rows as $row) {
if (!is_array($row) || !empty($row['delete'])) {
continue;
}
$out[] = ['region' => (string) ($row['region'] ?? ''), 'days' => (int) ($row['days'] ?? 0)];
}
usort($out, fn ($a, $b) => $a['region'] <=> $b['region']);
return $out;
}
}
3. Register it on the product adapter
In the bridge module's etc/di.xml, add the handler to the product adapter's sections. Magento merges the array with MageDrop's own sections:
<type name="MageDrop\Magento2\Model\Entity\Adapter\Product">
<arguments>
<argument name="sections" xsi:type="array">
<item name="acme_delivery_promises" xsi:type="object">Acme\DeliveryPromiseMageDrop\Model\DeliveryPromisesSection</item>
</argument>
</arguments>
</type>
Use the category adapter (Adapter\Category) or the CMS adapters (Adapter\CmsPage, Adapter\CmsBlock) in the same way for data on those forms.
4. Read the preview value on the storefront
While someone is previewing, MageDrop calls overlay() on the products the page loads. Have the storefront code use that value when it is there:
$rows = $product->getData('acme_delivery_promises')
?? $this->promises->getForProduct((int) $product->getId());
If the extension's template isn't yours to change, a small after plugin on the class that loads the rows does the same job.
If the output is cached in a block cache, MageDrop already adds the preview to block cache keys, so previews and the live page don't share cached HTML.
5. Deploy and test
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:flush
Then, on a test product:
- Change a row on the Delivery promises tab and choose Save & Stage. The release shows one change, Acme Delivery Promises, with the rows before and after.
- Save & Stage again without changing the tab. Nothing new should be staged: if it is,
normalise()is returning a different shape for the form and the table. - Preview the release and check the product page.
- Deploy, check the table, then roll back and check the rows are exactly as before.
Store-view data
If your data can differ per store view, return true from isScopable(), read and write it for $storeId, and return whether the store view has its own value from isOverridden(). When the merchant ticks "Use Default Value", extract() should return Value::inherit(), and apply() should remove the store view's own value when it receives one. MageDrop then rolls back to exactly what was there before: the store view's old value, or no value of its own.
If "overridden or not" doesn't describe your data (one store view might override some rows but not others), also implement CapturesPreviousInterface and return the store view's full state from previous(). A rollback applies that value as it is.
Rules of thumb
- Only return a field from
extract()when it is in the POST. Otherwise a form without your tab would stage "empty" and wipe the data. - Normalise. Sort rows, cast types and drop form-only keys such as
record_idanddelete, so unchanged data compares equal. - Don't persist in
apply(). Set the data on the entity, or remember it and write it inafterSave(). That way nothing is written if the entity save fails. - Never write in
overlay(). Preview must not change anything. - Keep values plain: strings, numbers, booleans and arrays of them. Images should be media paths, staged with
Value::image().
Custom content types
A whole entity of your own, such as store locations, FAQs or a blog post, can become a MageDrop content type too. It needs:
- An adapter implementing
MageDrop\Magento2\Model\Entity\AdapterInterface, usually by extendingAbstractAdapter, and registered in theadaptersarray ofMageDrop\Magento2\Model\Entity\AdapterPool. It describes how to load and save the entity, its admin edit route and whether it supports store views. - The MageDrop button on its admin form, an
aroundplugin on its Save controller that extendsMageDrop\Magento2\Plugin\Adminhtml\StageSavePlugin, and a plugin on its form data provider that extendsLoadChangesPlugin. The built-in CMS block versions are the simplest to copy. - A frontend plugin that calls the adapter's
overlay()where the storefront loads the entity, so preview works.
The module reports its content types to MageDrop when it connects, so the new type appears in the dashboard under its own name, with no change on the MageDrop side.