This is the second part of the article. In the first article we created the user interface for inputting the shipping comment, prepared the structure for passing the data as an extension attribute and set the input field to be visible only when specific shipping method was selected. You may want to start with the first article and then come back.

How to create comments box for Magento 2 shipping methods
In this article we will work a bit on Magento 2 checkout and create a new feature - adding a comment box in shipping sections. The use case is very simple - often times merchants want to allow customers to enter comments regarding their shipping choice. In native Magento this is not possible. This w…

In this article we will work on data processing and saving in the database. Lets get started.

Add extension attribute

First thing we need to do is define our extension attribute.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Api/etc/extension_attributes.xsd">
    <extension_attributes for="Magento\Quote\Api\Data\PaymentInterface">
        <attribute code="comment" type="string"/>
    </extension_attributes>
</config>
etc/extension_attributes.xml

We are going to use Magento\Quote\Api\Data\PaymentInterface which is extensible data interface and allows us to define attributes which we can use. In the first part of this article we set the value for the extension attribute. The values comes from our custom component which is rendered on the shipping step.

paymentData['extension_attributes']['comment'] = $('[name="shipping_comment"]').val();
view/frontend/web/js/action/shipping-comment-processor.js

Create new table

One of the main advantages of extension attributes is the fact that we do not need to modify the original database schema and we can use our own storage to persist the data. This means that we need to implement this part from scratch. In this example we will use a database table that references the order using a foreign key. This will allow us to persist the data separately and also fetch it easily. Although database schema approach is the suggested way starting from 2.3 we will use the old InstallSchema script to create our table structure. We will look into reworking this using db schema in one of the future articles. Here is the install script.

<?php

namespace Techflarestudio\ShippingComment\Setup;

use Magento\Framework\DB\Adapter\AdapterInterface;
use Magento\Framework\Setup\InstallSchemaInterface;

/**
 * Class InstallSchema
 * @package Techflarestudio\ShippingComment\Setup
 */
class InstallSchema implements InstallSchemaInterface
{
    /**
     * @inheritDoc
     * @throws \Zend_Db_Exception
     */
    public function install(
        \Magento\Framework\Setup\SchemaSetupInterface $setup,
        \Magento\Framework\Setup\ModuleContextInterface $context
    ) {
        $installer = $setup;
        $installer->startSetup();

        $table = $installer->getConnection()->newTable(
            $installer->getTable('techflarestudio_shippingcomment_comment')
        )->addColumn(
            'comment_id',
            \Magento\Framework\DB\Ddl\Table::TYPE_INTEGER,
            null,
            [ 'identity' => true, 'nullable' => false, 'primary' => true, 'unsigned' => true ],
            'Entity ID'
        )->addColumn(
            'order_id',
            \Magento\Framework\DB\Ddl\Table::TYPE_INTEGER,
            null,
            [ 'nullable' => false,'unsigned' => 'true'],
            'Order ID'
        )->addColumn(
            'comment',
            \Magento\Framework\DB\Ddl\Table::TYPE_TEXT,
            255,
            [ 'nullable' => false ],
            'Comment'
        )->addColumn(
            'creation_time',
            \Magento\Framework\DB\Ddl\Table::TYPE_TIMESTAMP,
            null,
            [ 'nullable' => false, 'default' => \Magento\Framework\DB\Ddl\Table::TIMESTAMP_INIT ],
            'Creation Time'
        )->addColumn(
            'update_time',
            \Magento\Framework\DB\Ddl\Table::TYPE_TIMESTAMP,
            null,
            [ 'nullable' => false, 'default' => \Magento\Framework\DB\Ddl\Table::TIMESTAMP_INIT_UPDATE ],
            'Modification Time'
        )->addForeignKey(
            'order_id_sales_order',
            'order_id',
            $setup->getTable('sales_order'),
            'entity_id',
            AdapterInterface::FK_ACTION_CASCADE
        );
        $installer->getConnection()->createTable($table);
        $installer->endSetup();
    }
}
Setup/InstallSchema.php

A few things to note regarding this:

  • We are creating a new table techflarestudio_shippingcomment_comment
  • The primary key will be the comment_id field which will be simply incremental value that will ensure a unique identifier for each row
  • The shipping comment data itself will be saved in a field called comment which will be a text type column
  • We will also be saving reference to order. So we are adding a new column order_id which is a foreign key and references to sales_order
  • We are also adding updated_at and creation_time which helps with managing and fetching the data

Create CRUD model

Since we have a new database table we should be able to safely add, delete and modify any records in the table. For this we will create the CRUD model which consists of model class, resource model class, repository class and respective API implementations. In this article we will show only a brief part of the implementation as most of it is unnecessary and unrelated to the topic we are exploring. We will explore repositories and interface approach in a different article. Here are the relevant classes. Lets start with the Model:

<?php
namespace Techflarestudio\ShippingComment\Model;

/**
 * Class Comment
 * @package Techflarestudio\ShippingComment\Model
 */
class Comment extends \Magento\Framework\Model\AbstractModel implements
    \Techflarestudio\ShippingComment\Api\Data\CommentInterface,
    \Magento\Framework\DataObject\IdentityInterface
{
    const CACHE_TAG = 'techflarestudio_shippingcomment_comment';

    /**
     * Init
     */
    protected function _construct()
    {
        $this->_init(\Techflarestudio\ShippingComment\Model\ResourceModel\Comment::class);
    }

    /**
     * @inheritDoc
     */
    public function getIdentities()
    {
        return [self::CACHE_TAG . '_' . $this->getId()];
    }
}
Model/Comment.php

We will also be using repository class. We will leave the resource model, collection and interfaces for the reader. Those are fairly standard and do not contain any significant code. You may want to take a look at any of the native entities to see the required code.

<?php
namespace Techflarestudio\ShippingComment\Model;

use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Api\SearchResultsInterfaceFactory;
use Magento\Framework\Exception\CouldNotDeleteException;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;

use Techflarestudio\ShippingComment\Api\CommentRepositoryInterface;
use Techflarestudio\ShippingComment\Api\Data\CommentInterface;
use Techflarestudio\ShippingComment\Model\CommentFactory;
use Techflarestudio\ShippingComment\Model\ResourceModel\Comment as ObjectResourceModel;
use Techflarestudio\ShippingComment\Model\ResourceModel\Comment\CollectionFactory;

/**
 * Class CommentRepository
 * @package Techflarestudio\ShippingComment\Model
 */
class CommentRepository implements CommentRepositoryInterface
{
    protected $objectFactory;
    protected $objectResourceModel;
    protected $collectionFactory;
    protected $searchResultsFactory;

    /**
     * CommentRepository constructor.
     *
     * @param CommentFactory $objectFactory
     * @param ObjectResourceModel $objectResourceModel
     * @param CollectionFactory $collectionFactory
     * @param SearchResultsInterfaceFactory $searchResultsFactory
     */
    public function __construct(
        CommentFactory $objectFactory,
        ObjectResourceModel $objectResourceModel,
        CollectionFactory $collectionFactory,
        SearchResultsInterfaceFactory $searchResultsFactory
    ) {
        $this->objectFactory        = $objectFactory;
        $this->objectResourceModel  = $objectResourceModel;
        $this->collectionFactory    = $collectionFactory;
        $this->searchResultsFactory = $searchResultsFactory;
    }

    /**
     * @inheritDoc
     *
     * @throws CouldNotSaveException
     */
    public function save(CommentInterface $object)
    {
        try {
            $this->objectResourceModel->save($object);
        } catch (\Exception $e) {
            throw new CouldNotSaveException(__($e->getMessage()));
        }
        return $object;
    }

    /**
     * @inheritDoc
     */
    public function getById($id)
    {
        $object = $this->objectFactory->create();
        $this->objectResourceModel->load($object, $id);
        if (!$object->getId()) {
            throw new NoSuchEntityException(__('Object with id "%1" does not exist.', $id));
        }
        return $object;
    }

    /**
     * @inheritDoc
     */
    public function delete(CommentInterface $object)
    {
        try {
            $this->objectResourceModel->delete($object);
        } catch (\Exception $exception) {
            throw new CouldNotDeleteException(__($exception->getMessage()));
        }
        return true;
    }

    /**
     * @inheritDoc
     */
    public function deleteById($id)
    {
        return $this->delete($this->getById($id));
    }

    /**
     * @inheritDoc
     */
    public function getList(SearchCriteriaInterface $criteria)
    {
        $searchResults = $this->searchResultsFactory->create();
        $searchResults->setSearchCriteria($criteria);
        $collection = $this->collectionFactory->create();
        foreach ($criteria->getFilterGroups() as $filterGroup) {
            $fields = [];
            $conditions = [];
            foreach ($filterGroup->getFilters() as $filter) {
                $condition = $filter->getConditionType() ? $filter->getConditionType() : 'eq';
                $fields[] = $filter->getField();
                $conditions[] = [$condition => $filter->getValue()];
            }
            if ($fields) {
                $collection->addFieldToFilter($fields, $conditions);
            }
        }
        $searchResults->setTotalCount($collection->getSize());
        $sortOrders = $criteria->getSortOrders();
        if ($sortOrders) {
            /** @var SortOrder $sortOrder */
            foreach ($sortOrders as $sortOrder) {
                $collection->addOrder(
                    $sortOrder->getField(),
                    ($sortOrder->getDirection() == SortOrder::SORT_ASC) ? 'ASC' : 'DESC'
                );
            }
        }
        $collection->setCurPage($criteria->getCurrentPage());
        $collection->setPageSize($criteria->getPageSize());
        $objects = [];
        foreach ($collection as $objectModel) {
            $objects[] = $objectModel;
        }
        $searchResults->setItems($objects);
        return $searchResults;
    }
}
Model/Comment.php

Create plugin for saving the data

Once we have our database structure and CRUD models prepared we can proceed with setting up the logic for saving the data. Since we are using PaymentInterface for our extension attribute we need to make sure that the action we observe has access to this data. The payment extension attributes are available when the payment is saved which means that it would be great place to hook in and persist our data. In addition we need the order to be placed when we save this - in order to relate the data in our storage.

One thing to note is that the action for saving payment information differs for guest and logged in sessions which means that there are 2 separate methods we need to hook into:

  1. \Magento\Checkout\Model\GuestPaymentInformationManagement::savePaymentInformationAndPlaceOrder
  2. \Magento\Checkout\Model\PaymentInformationManagement::savePaymentInformationAndPlaceOrder
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Checkout\Model\PaymentInformationManagement">
        <plugin
            name="techflarestudio_shippingcomment_magento_checkout_model_paymentinformationmanagement"
            type="Techflarestudio\ShippingComment\Plugin\Magento\Checkout\Model\PaymentInformationManagement"/>
    </type>
    <type name="Magento\Checkout\Model\GuestPaymentInformationManagement">
        <plugin name="techflarestudio_shippingcomment_magento_checkout_model_guestpaymentinformationmanagement"
                type="Techflarestudio\ShippingComment\Plugin\Magento\Checkout\Model\GuestPaymentInformationManagement"/>
    </type>
</config>
etc/di.xml

The logic is fairly similar for both plugins so we will show just the guest user one. We are going to use the after plugin because we are able to access all the data we need - the result returns order id and original argument paymentMethod contains the extension attributes we set earlier.

<?php
namespace Techflarestudio\ShippingComment\Plugin\Magento\Checkout\Model;

use Magento\Quote\Api\Data\PaymentInterface;
use Techflarestudio\ShippingComment\Model\CommentFactory;
use Techflarestudio\ShippingComment\Model\CommentRepository;

/**
 * Class GuestPaymentInformationManagement
 * @package Techflarestudio\ShippingComment\Plugin\Magento\Checkout\Model
 */
class GuestPaymentInformationManagement
{
    /**
     * @var CommentFactory
     */
    protected $commentFactory;

    /**
     * @var CommentRepository
     */
    protected $commentRepository;

    /**
     * GuestPaymentInformationManagement constructor.
     * @param CommentFactory $commentFactory
     * @param CommentRepository $commentRepository
     */
    public function __construct(
        CommentFactory $commentFactory,
        CommentRepository $commentRepository
    ) {
        $this->logger = $logger;
        $this->commentFactory = $commentFactory;
        $this->commentRepository = $commentRepository;
    }

    public function afterSavePaymentInformationAndPlaceOrder(
        \Magento\Checkout\Model\GuestPaymentInformationManagement $subject,
        $orderId,
        $cartId,
        $email,
        PaymentInterface $paymentMethod
    ) {
        $shippingComment = $paymentMethod->getExtensionAttributes();
        $comment = $shippingComment->getComment();

        if ($comment && $orderId) {
            $shippingCommentData = [
                'order_id' => $orderId,
                'comment' => $comment
            ];

            $shippingComment = $this->commentFactory->create();
            $shippingComment->setData($shippingCommentData);

            $this->commentRepository->save($shippingComment);
        }
    }
}
Plugin/Magento/Checkout/Model/GuestPaymentInformationManagement.php

Lots to digest here:

  • Firstly we are using after plugin and including the original arguments up until paymentMethod
  • The we verify if both order id and comment extension attributes are set
  • We create our shipping comment entity using the factory method for the model class we created previously Techflarestudio\ShippingComment\Model\CommentFactory
  • We are setting the data and saving the entity using our repository class Techflarestudio\ShippingComment\Model\CommentRepository

For a single store customization the current implementation is sufficient. In future articles we will look into how we can rework this logic to use API interfaces instead of accessing the factory directly.

Make sure to refresh the caches, make sure generated files are cleared. Go to checkout and enter the comment in the shipping section, finish the order. If you followed along then you should be able to see the values persisted in the database. You can verify using:

select * from techflarestudio_shippingcomment_comment;

That is it for today. In the next articles we will work on this even further by using the values we saved - loading the extension attributes in certain areas and showing the values in both customer and admin order views. Stay tuned for part 3.