Understanding Carrier Configuration
A carrier has two configuration files: Config/carriers.php registers it in config('carriers') with its defaults, and Config/system.php gives it settings under Configure → Sales → Shipping Methods. This page covers the keys of both, how a setting is resolved, and how to configure carriers with several services or an external rate API.
The Configuration File
This is the file you created in Creating Your First Shipping Method:
File: packages/Webkul/CustomExpressShipping/src/Config/carriers.php
<?php
return [
'custom_express_shipping' => [
'code' => 'custom_express_shipping',
'title' => 'Express Delivery (1-2 Days)',
'description' => 'Premium express shipping with tracking and insurance',
'active' => true,
'default_rate' => '19.99',
'type' => 'per_order',
'class' => 'Webkul\CustomExpressShipping\Carriers\CustomExpressShipping',
],
];| Key | Required | Description |
|---|---|---|
class | Yes | The fully qualified carrier class, matched case-sensitively. The only key core reads from this file when it collects rates |
code | No | The carrier code. Identity comes from the class's $code, so keep it, this key and the array key identical: lowercase with underscores, and unique across carriers |
title | No | Default display name, used until an admin saves one |
description | No | Default description. The carrier copies it into the rate's method_description, which the shipping step prints after the method title |
active | No | Default enabled state, used until an admin saves one. Both core carriers ship true |
default_rate | No | Default base rate, as a string or float. Core's flat rate ships '10' |
type | No | Pricing model: per_order or per_unit. Core's flat rate ships per_unit; the free carrier has no type |
Everything except class is a default: $this->getConfigData('title') reads the admin-saved value first and falls back to this file (see Configuration Value Resolution).
Custom Keys
A carrier can carry any other keys it needs, and reads them through getConfigData() like the core ones:
<?php
return [
'custom_express_shipping' => [
'code' => 'custom_express_shipping',
// ...
'class' => 'Webkul\CustomExpressShipping\Carriers\CustomExpressShipping',
'free_shipping_threshold' => '100.00',
'supports' => [
'tracking' => true,
'insurance' => true,
],
'api' => [
'endpoint' => env('EXPRESS_SHIPPING_API_URL'),
'timeout' => 30,
],
],
];| Key | Read with | Editable in the admin |
|---|---|---|
A flat key that matches a field name in system.php | getConfigData('free_shipping_threshold') | Yes; this file supplies the default |
| A flat key with no matching field | getConfigData('free_shipping_threshold') | No |
| A nested key | getConfigData('supports.tracking'), getConfigData('api.timeout') | No; it is only ever read from this file |
Put what a merchant must change (rates, thresholds, credentials as password fields) in system.php, and keep what only a developer changes (service codes, endpoints, timeouts) here.
System Configuration
The section's key must be sales.carriers.{code}: that places it on the Shipping Methods page and is the path getConfigData() reads. The file is in Step 4, and every item key, field key and field type is on System Configuration. What a carrier section needs, as core's own sections in packages/Webkul/Admin/src/Config/system.php show:
- Keep a boolean
activefield.ConfigurationController::store()readsactivefrom every carrier section when the Shipping Methods page is saved, and refuses to save when none is switched on. sortorders the sections. Core's Free Shipping is1and Flat Rate is2, so3lists yours after them.- Defaults go in
carriers.php. A field'sdefaultis consulted only when neither the database norcarriers.phphas a value. depends => 'active:1'hides the other fields while the method is off, and the admin neither renders nor validates a hidden field, so the page saves in that state. Core's carrier sections keep the toggle last and putdependsonly on the title and rate; either layout works.required_if:active,1is enforced only in the browser. On the server the rule looks for a top-levelactiveinput, as in core's carrier sections; writerequired_if:sales.carriers.custom_express_shipping.active,1if the server must enforce it too.channel_basedgives each channel its own rate and title;locale_basedontitleanddescriptionlets them be translated.
How Bagisto Uses the Configuration
- It is merged, never replaced. Your provider's
register()merges the file intocarriers, andmergeConfigFrom()keeps a key that is already set, so reusing the codeflatrateorfreeleaves core's class in place. To change a core carrier, extend its class and point the entry at your subclass from your provider'sboot(), as inconfig(['carriers.flatrate.class' => FlatRate::class])with yourFlatRateimported; Overriding a Core Type explains whyboot(). - Every entry is asked for rates.
Webkul\Shipping\Shipping::collectRates()calls every carrier inconfig('carriers'), andWebkul\CartRule\Repositories\CartRuleRepository::getShippingMethods()offers every entry as a cart-rule condition.Shipping::getShippingMethods()lists the available ones, though no core screen calls it. - A carrier is created with
new, inShipping::collectRates()andShipping::getShippingMethods(), so it can't take constructor dependencies. Resolve repositories or services insidecalculate()withapp(). Payment methods differ: they are resolved withapp($class).
File: packages/Webkul/Shipping/src/Shipping.php
foreach (Config::get('carriers') as $shippingMethod) {
$object = new $shippingMethod['class'];
if ($rates = $object->calculate()) {
// ...
}
}Configuration Value Resolution
getConfigData($field) reads core()->getConfigData('sales.carriers.{code}.{field}'): a value saved for the requested channel (and locale, for a locale_based field) wins, then config('carriers.{code}.{field}') from this file, then the field's default. There is no fallback between channels or locales, so before anything is saved getConfigData('default_rate') returns '19.99' from the file above. System Configuration covers the lookup for every key.
Common Configuration Patterns
Multi-Service Carrier
When one carrier offers several speeds, the simplest model is one class returning several rates. calculate() returns an array of CartShippingRate objects, one per service, each with its own method and method_title, and the checkout groups them under the shared carrier_title:
/**
* Calculate one rate per service level.
*
* @return array|false
*/
public function calculate()
{
if (! $this->isAvailable()) {
return false;
}
return [
$this->rate('express_standard', trans('express::app.standard'), 9.99),
$this->rate('express_priority', trans('express::app.priority'), 19.99),
];
}
/**
* Build one rate of this carrier.
*/
protected function rate(string $method, string $title, float $basePrice): CartShippingRate
{
$rate = new CartShippingRate;
$rate->carrier = $this->getCode();
$rate->carrier_title = $this->getConfigData('title');
$rate->method = $method;
$rate->method_title = $title;
$rate->method_description = $this->getConfigData('description');
$rate->price = core()->convertPrice($basePrice);
$rate->base_price = $basePrice;
return $rate;
}rate() is your own helper, in a carrier that imports Webkul\Checkout\Models\CartShippingRate; express:: stands for your package's translation namespace. Each method string must be unique across every carrier, because Shipping::isMethodCodeExists() matches the customer's choice against all collected rates.
The alternative is one class per service, each with its own entry:
<?php
return [
'express_standard' => [
'code' => 'express_standard',
'title' => 'Express Standard (2-3 Days)',
'rate' => '9.99',
'class' => 'Vendor\Express\Carriers\ExpressStandard',
'days' => '2-3',
],
'express_priority' => [
'code' => 'express_priority',
'title' => 'Express Priority (1-2 Days)',
'rate' => '19.99',
'class' => 'Vendor\Express\Carriers\ExpressPriority',
'days' => '1-2',
'max_weight' => 30.0,
],
];- One class, several rates: one admin section, one enable toggle, one title and one cart-rule condition for every service; the services are fixed in code.
- One class per service: each service has its own section, toggle, rate fields and cart-rule condition. Choose it when each service needs its own settings, and share the rate-building code in a base class.
Region, Option and API-Based Carriers
These follow the same rules: structural data can live here as custom keys, anything the merchant must change needs a system.php field, and the decision is made in calculate() or isAvailable() from the cart.
- By region: read the destination from
Cart::getCart()->shipping_address(country,stateandpostcode, the only address fields the cart page's shipping estimator provides), and returnfalsefor a destination you don't serve, or overrideisAvailable()as shown in Understanding the Carrier Class. - By service option: the shipping step only lets the customer choose a rate, so offer each combination (with or without insurance, say) as a rate of its own.
- From an API: rates are collected on every save of the address step, again when the method is saved, and by the cart page's shipping estimator.
collectRates()doesn't catch exceptions, so a failing request fails the checkout request. Cache the courier's answer, set a timeout, and returnfalseor a fallback rate when the API doesn't answer.
'fedex_integration' => [
'code' => 'fedex_integration',
'title' => 'FedEx Express',
'class' => 'Vendor\FedEx\Carriers\FedExIntegration',
'services' => [
'FEDEX_GROUND' => 'FedEx Ground',
'FEDEX_2_DAY' => 'FedEx 2Day',
'PRIORITY_OVERNIGHT' => 'FedEx Priority Overnight',
],
'timeout' => 30,
'fallback_rate' => '24.99',
],The service list and timeout are developer settings; the account number and API secret belong in password fields in system.php, so each channel can hold its own.
Things to Watch
- Define the defaults a carrier needs (
active,title,default_rate,type) here, so it works before an admin saves anything. - Ship the method disabled (
'active' => false) when it can't quote until the store owner enters a rate or credentials, and never ship a placeholder rate a customer could be charged. - Use descriptive codes. The code is what an admin sees among cart-rule conditions and what is stored in
cart_shipping_rates.carrier, so avoid abbreviations such asped. - Call
env()only in this file. It is cached with the application configuration; called from the carrier class, it returnsnullonce the configuration is cached. - Write titles and descriptions for the customer. They are what the shipping step shows, so say what the customer gets, such as "Express Delivery (1-2 Business Days)".
Next Step
Next, see what the carrier class inherits and how calculate() builds a rate.
Continue to: Understanding the Carrier Class
