Shipping method customizado no Magento 2: crie um módulo de carrier
A maioria das lojas supera o Flat Rate e o Table Rates em algum momento. Talvez o preço dependa de uma lista de CEPs que muda todo mês, talvez venha de uma API de transportadora, ou talvez alguns produtos nunca possam ser enviados por determinado método. Quando os carriers nativos não conseguem expressar a regra, a resposta limpa é um carrier customizado em um módulo próprio.
Este guia monta um do zero para Magento Open Source e Adobe Commerce 2.4.8. Cobre os arquivos de configuração, o carrier model, como as tarifas são coletadas e cacheadas, e os detalhes que costumam causar bugs em produção: frete grátis vindo de cart rules, restrições de país e chamadas externas lentas.
Como o Magento coleta as tarifas de frete
Quando o cliente informa um endereço no checkout, ou o bloco de estimativa no carrinho roda, o Magento monta um Magento\Quote\Model\Quote\Address\RateRequest com o destino, o peso do pacote, o valor do pacote e os itens do carrinho. O Magento\Shipping\Model\Shipping então percorre todos os carriers ativos na seção de config carriers e pede tarifas a cada um.
Cada carrier responde com um Magento\Shipping\Model\Rate\Result contendo um ou mais métodos, ou com um erro, ou com false quando não se aplica. As tarifas são guardadas na tabela quote_shipping_rate para o endereço, então não são recalculadas a cada carregamento de página. São coletadas de novo quando o endereço ou o carrinho mudam.
Isso dá ao carrier um trabalho claro: olhar a request, decidir se se aplica e devolver preços.
Estrutura do módulo
Vou usar Vendor_Shipping e o code de carrier vendorship. Você precisa do registration.php e do etc/module.xml de sempre. Adicione um sequence em Magento_Shipping e Magento_Quote para a sua config carregar depois das deles. Se você nunca construiu um módulo, comece por construir um módulo Magento 2 do zero.
Configuração padrão no config.xml
Todo carrier fica em carriers/<code>. O nó model é como o Magento encontra a sua classe. Sem ele o carrier é ignorado, mesmo estando ativo.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
<default>
<carriers>
<vendorship>
<active>0</active>
<model>Vendor\Shipping\Model\Carrier\VendorShip</model>
<title>Local Delivery</title>
<name>Same day</name>
<price>10.00</price>
<sallowspecific>0</sallowspecific>
<showmethod>0</showmethod>
<specificerrmsg>This shipping method is not available for your address.</specificerrmsg>
<sort_order>100</sort_order>
</vendorship>
</carriers>
</default>
</config>
title é o nome do carrier e name é o nome do método. O checkout mostra os dois juntos. showmethod controla se o método continua listado, com a mensagem de erro, quando não se aplica.
Configurações do admin no system.xml
Coloque o grupo dentro da seção carriers existente para que apareça em Stores, Configuration, Sales, Delivery Methods ao lado dos carriers do core.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
<system>
<section id="carriers">
<group id="vendorship" translate="label" type="text" sortOrder="900" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Local Delivery</label>
<field id="active" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="1">
<label>Enabled</label>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
</field>
<field id="title" translate="label" type="text" sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Title</label>
</field>
<field id="name" translate="label" type="text" sortOrder="30" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Method Name</label>
</field>
<field id="price" translate="label" type="text" sortOrder="40" showInDefault="1" showInWebsite="1">
<label>Price</label>
<validate>validate-number validate-zero-or-greater</validate>
</field>
<field id="postcodes" translate="label comment" type="textarea" sortOrder="50" showInDefault="1" showInWebsite="1">
<label>Allowed Postcode Prefixes</label>
<comment>One prefix per line.</comment>
</field>
<field id="sallowspecific" translate="label" type="select" sortOrder="60" showInDefault="1" showInWebsite="1">
<label>Ship to Applicable Countries</label>
<frontend_class>shipping-applicable-country</frontend_class>
<source_model>Magento\Shipping\Model\Config\Source\Allspecificcountries</source_model>
</field>
<field id="specificcountry" translate="label" type="multiselect" sortOrder="70" showInDefault="1" showInWebsite="1">
<label>Ship to Specific Countries</label>
<source_model>Magento\Directory\Model\Config\Source\Country</source_model>
<can_be_empty>1</can_be_empty>
</field>
<field id="showmethod" translate="label" type="select" sortOrder="80" showInDefault="1" showInWebsite="1">
<label>Show Method if Not Applicable</label>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
</field>
<field id="sort_order" translate="label" type="text" sortOrder="90" showInDefault="1" showInWebsite="1">
<label>Sort Order</label>
</field>
</group>
</section>
</system>
</config>
A config de frete geralmente tem scope de website porque quotes e preços são por website. Deixe o scope de store view só para os labels.
O carrier model
A classe estende Magento\Shipping\Model\Carrier\AbstractCarrier e implementa Magento\Shipping\Model\Carrier\CarrierInterface. A classe pai dá a você o getConfigData() e o getConfigFlag(), que leem de carriers/vendorship/* no scope certo, com base em $_code.
<?php
declare(strict_types=1);
namespace Vendor\Shipping\Model\Carrier;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Quote\Model\Quote\Address\RateRequest;
use Magento\Quote\Model\Quote\Address\RateResult\ErrorFactory;
use Magento\Quote\Model\Quote\Address\RateResult\MethodFactory;
use Magento\Shipping\Model\Carrier\AbstractCarrier;
use Magento\Shipping\Model\Carrier\CarrierInterface;
use Magento\Shipping\Model\Rate\Result;
use Magento\Shipping\Model\Rate\ResultFactory;
use Psr\Log\LoggerInterface;
class VendorShip extends AbstractCarrier implements CarrierInterface
{
protected $_code = 'vendorship';
protected $_isFixed = true;
public function __construct(
ScopeConfigInterface $scopeConfig,
ErrorFactory $rateErrorFactory,
LoggerInterface $logger,
private readonly ResultFactory $rateResultFactory,
private readonly MethodFactory $rateMethodFactory,
array $data = []
) {
parent::__construct($scopeConfig, $rateErrorFactory, $logger, $data);
}
public function collectRates(RateRequest $request)
{
if (!$this->getConfigFlag('active')) {
return false;
}
if (!$this->isPostcodeAllowed((string) $request->getDestPostcode())) {
return false;
}
$price = (float) $this->getConfigData('price');
if ($request->getFreeShipping() === true) {
$price = 0.0;
}
$method = $this->rateMethodFactory->create();
$method->setCarrier($this->_code);
$method->setCarrierTitle($this->getConfigData('title'));
$method->setMethod($this->_code);
$method->setMethodTitle($this->getConfigData('name'));
$method->setPrice($price);
$method->setCost($price);
/** @var Result $result */
$result = $this->rateResultFactory->create();
$result->append($method);
return $result;
}
public function getAllowedMethods(): array
{
return [$this->_code => $this->getConfigData('name')];
}
private function isPostcodeAllowed(string $postcode): bool
{
$postcode = strtoupper(str_replace([' ', '-'], '', $postcode));
$prefixes = preg_split('/\R/', (string) $this->getConfigData('postcodes'), -1, PREG_SPLIT_NO_EMPTY);
if (!$prefixes) {
return true;
}
foreach ($prefixes as $prefix) {
if (str_starts_with($postcode, strtoupper(trim($prefix)))) {
return true;
}
}
return false;
}
}
O code do método que vai parar no pedido é carrier_method, então aqui é vendorship_vendorship. Se o seu carrier oferece vários serviços, adicione um método por serviço com valores diferentes de setMethod() e liste todos em getAllowedMethods(). Outras partes do Magento, como as condições de cart price rule por shipping method, leem essa lista.
Depois do deploy, rode bin/magento setup:upgrade e bin/magento cache:flush, habilite o carrier no admin e teste com um endereço que case com as suas regras.
Restrições de país vêm de graça
Você não precisa verificar sallowspecific e specificcountry por conta própria. Antes de chamar o collectRates(), o shipping model chama o checkAvailableShipCountries() no carrier, que lê essas duas configurações. Se o país não é permitido, ele retorna um resultado de erro quando o showmethod está ligado, ou pula o carrier. Mantenha as suas próprias checagens para as regras que o Magento não conhece, como a lista de CEPs acima.
Frete grátis vindo de cart price rules
Esse é o bug mais comum que vejo em carriers customizados. Uma cart price rule com a ação Free Shipping define flags no endereço e nos itens, e a request as expõe pelo getFreeShipping(). Os carriers do core leem essa flag. Um carrier customizado só a respeita se você escrever o código para isso.
Dois casos importam:
- "For shipment with matching items" define frete grátis no endereço inteiro. O
$request->getFreeShipping()é true. - "For matching items only" marca itens individuais. Se você calcula o preço por peso ou por item, pule esses itens no cálculo. Cada item de
$request->getAllItems()temgetFreeShipping().
Decida também se o seu método deve ser afetado. Uma regra que dá frete padrão grátis nem sempre deve tornar a entrega expressa grátis. Lojistas normalmente esperam uma configuração para isso, então adicione uma ao system.xml em vez de fixar a resposta no código.
Devolvendo erros ao cliente
Retornar false esconde o método em silêncio. Quando os clientes precisam saber por que um método está faltando, retorne um objeto de erro.
$error = $this->_rateErrorFactory->create();
$error->setCarrier($this->_code);
$error->setCarrierTitle($this->getConfigData('title'));
$error->setErrorMessage($this->getConfigData('specificerrmsg'));
return $error;
Use com cuidado. Uma lista de mensagens de erro vermelhas sob os shipping methods parece quebrada para a maioria dos compradores. Eu prefiro mostrar erros só quando o método é algo que o cliente espera explicitamente, como retirada na loja para a cidade dele.
Tarifas de uma API externa
Quando o preço vem de uma API de transportadora, o collectRates() vira a parte mais lenta do checkout. Algumas regras o mantêm sob controle:
- Defina um timeout curto. Poucos segundos no máximo. Se a API não responder, retorne
falseou um preço de fallback da config em vez de bloquear o cliente. - Faça cache da resposta. Monte uma chave com destino, peso e valor e guarde a resposta no cache do Magento por alguns minutos. A mesma request pode rodar várias vezes enquanto o cliente edita o endereço.
- Registre as falhas. Use um arquivo de log dedicado para que uma API fora do ar fique visível. Veja loggers customizados com Monolog.
- Considere o AbstractCarrierOnline. Se você precisa de rastreamento, pacotes e etiquetas de envio, o
Magento\Shipping\Model\Carrier\AbstractCarrierOnlineé a classe base que os carriers online do core usam. Ele traz mais estrutura e mais dependências no constructor, então só use quando precisar desses recursos.
Restringindo o método por produto
Um pedido comum é "este produto só pode ser enviado pelo método X". A request dá a você os itens, então você pode verificar um atributo do produto dentro do collectRates() e retornar false se algum item não for permitido. Lembre que itens de produtos configurable aparecem como pai e filho. Verifique o getParentItem() e decida qual deles guarda o atributo, e carregue o atributo pelo produto do quote item, que só inclui atributos marcados como usados na sales quote. Adicione o atributo em etc/catalog_attributes.xml no grupo quote_item para que seja carregado.
Testando
Um carrier é fácil de testar em unit tests porque o collectRates() recebe um objeto de request simples. Monte um RateRequest, defina destino e peso, faça mock das factories e verifique o preço. Para o fluxo completo, um teste de integração que cria uma quote, define um endereço e chama o collectShippingRates() no endereço de entrega pega erros de configuração. Mais sobre isso em testes de integração no Magento 2.
FAQ
Por que meu shipping method customizado não aparece no checkout?
Confira se o carrier está ativo no scope de website certo, se o nó model no config.xml aponta para uma classe existente, se o país é permitido e se o collectRates não retorna false para o seu endereço de teste. Depois recarregue o endereço para que as tarifas sejam coletadas de novo.
Um carrier pode retornar vários shipping methods?
Pode. Adicione um objeto de método por serviço ao resultado, cada um com seu próprio code de método, e liste todos no getAllowedMethods.
Como faço meu carrier respeitar cart rules de frete grátis?
Leia o getFreeShipping na request para frete grátis no nível do endereço e em cada item para frete grátis no nível do item, e defina o preço como zero ou pule esses itens no seu cálculo.
Devo chamar uma API de transportadora dentro do collectRates?
Pode, mas use um timeout curto, faça cache das respostas por destino, peso e valor e tenha um fallback para que uma API lenta não bloqueie o checkout.
Atendo como freelancer em Magento e Adobe Commerce, incluindo lógica customizada de frete e checkout. Se você precisa de ajuda com isso, fale comigo.
Artigos relacionados
Método de pagamento customizado no Magento 2 com a Payment Gateway API
Crie um método de pagamento customizado no Magento 2.4 com a Payment Gateway API: virtual type de facade, config, commands e o renderer JS do checkout, passo a passo.
Taxa customizada no Magento 2: um totals collector que funciona
Adicione uma taxa customizada no Magento 2 com um quote totals collector: sales.xml, collect e fetch, exibição no carrinho e checkout, cópia para pedidos, invoices e PDFs.
Guest checkout e contas de cliente no Magento 2 explicados
Como o Magento 2 trata guest checkout e contas de cliente: configuração, criação de conta depois do pedido, vínculo seguro de pedidos de visitante e checagem de e-mail.
Magento 2 Cart Price Rules: como criar uma condição customizada
Crie uma condição customizada nas cart price rules do Magento 2: o evento combine, o model da condition com validate, atributos de promo rule e armadilhas de performance.