Shipping Carriers
This page describes the ShippingCarrier model, its two categorization dimensions, the shipping_by_province feature, dashboard settings, and how checkout resolves which carriers to offer the user. SN integration is covered from the overview and the sibling topic docs.
Overview
A carrier is the shipping operator configuration the tenant offers the user in checkout. It decides how shipping is charged, where delivery happens, and which province applies.
The SN is always born pointing to a carrier. From the carrier come the delivery type, the visible title, and the address or schedule that travel to the SN snapshot.
Rows in shipping_carriers are categorized along two independent dimensions:
type: distinguishes home delivery (delivery) from pickup at a physical location (warehouse). Both can coexist for the same tenant.- Presence of
state_code: separates "manual" carriers (without state) from the province flow (with state).
Manual carriers are the original form and are loaded from the legacy settings CRUD, gated only by the physicals_goods feature. The tenant can define both home delivery carriers and pickup points without binding them to a province.
Rows with a populated state_code require the shipping_by_province feature, which enables a separate UI for loading carriers tied to a state. The code treats them as two independent categories via scopes: provinceRates() for delivery with state, and pickupPoints() for warehouse with state.
A pickup point can live in two flavors: a legacy pickup created from the old CRUD, without an associated state, or a province pickup created from the province feature. Operationally they are the same (type = warehouse, address and schedule in address) but settings, checkout queries, and the snapshot that travels to the SN treat them differently.
What shipping_carriers stores
Each row represents a shipping operator configured by the tenant. Beyond the standard columns (id, tenant_id, timestamps), the ones carrying the subdomain logic are few.
type discriminates home delivery (delivery) from pickup at a physical location (warehouse). title is the name the user sees in checkout — for province rates it is auto-generated from the state name when saved. description is additional visible text; for pickup points it is usually left empty and the model synthesizes it from the address and schedule at runtime.
There are two flags. is_free activates free shipping and, when on, the amount is charged as zero regardless of the prices table. is_available is a soft toggle the tenant uses to suspend a carrier without deleting it; checkout respects it via the province rates and pickup points scopes.
Location is stored in three fields. country_code and state_code travel in ISO and are only populated in rows with state; manual carriers have them as null. country_code is populated by the service from the tenant's signUpIntent when saving.
The address column is a JSON { address, schedule } that only applies to warehouse. It stores the location address and pickup schedule, which checkout shows to the user and the SN copies to the snapshot.
To display the carrier to the user, the description is resolved by type: for pickup points it is synthesized from the address and schedule because the description column is usually empty; for delivery the description is returned as-is. The human name of the state is composed from a virtual attribute using the ISO codes.
order is an integer that defines the display order in the UI; checkout and settings queries sort by this column.
At the model behavior level: it does not use SoftDeletes, so the controller's destroy() performs a hard delete. There is a composite index on (tenant_id, type, state_code) that covers the checkout queries.
Free shipping
The is_free flag applies to both types. When active, the carrier is offered in checkout with a zero amount; when saving with is_free = true from the dashboard, the trait deletes all existing rows in prices.
The flag is the only free shipping mechanism in the system: coupons, plans, and PricingService do not contemplate free shipping.
The shipping payment and the SN are still created with amount = 0 — the flow is not short-circuited. This preserves the trace that the shipment existed even when there is no charge.
Multi-currency pricing
Carriers do not store a price in the main table. The price lives in prices via the polymorphic priceMorph relationship, defined by the ShippingHasPrices trait. Each carrier can have a different price per currency; amounts are persisted in cents and CurrencyHelper handles the conversion to the main unit.
The trait exposes two write paths with different semantics.
When creating a new carrier, a simple insertion is used that accepts zero amounts and does not touch previous rows.
When updating an existing one, an intelligent diff is performed: the intersection with current prices is calculated, those that changed or disappeared are deleted, and new ones are inserted. The update only accepts amounts greater than zero; passing an empty array deletes everything, and that is the path the is_free flag uses.
The behavior when a currency has no loaded price is invisibility in checkout, not an error. If a carrier has no price in the active currency and is also not free, the checkout service discards it before building the list the user sees. The operational consequence: if the tenant adds a new currency to the store and forgets to load the carrier price, the user will not see it offered. It is advisable to populate all enabled currencies when registering a carrier.
The ProvinceShippingService service
App\Domains\Commerce\Services\ProvinceShippingService is the piece that orchestrates the shipping_by_province feature. It is built as a class with all static methods, no DI or constructor, and concentrates the queries, writes, and groupings that the feature triggers.
All writes go through an initial guard that aborts with 403 if the tenant does not have the feature active. Reads do not go through the guard: checkout queries the scopes even for tenants without the feature, but those tenants have no records with state_code and fall through to the legacy path.
Checkout queries
There is a base engine that, given an optional state_code and the tenant's list of active currencies, returns the applicable carriers. If state_code comes as null it returns the manual carriers that have a price in some active currency or are free; if it has a value it returns the carriers tied to that same province under the same currency filter.
Manual carriers and carriers with state are never mixed in the same response: state_code splits the query in two.
On top of that result, a grouping is applied that discards carriers with a zero amount that are not free, sorts by ascending price, and groups by type. The checkout blade consumes that grouped array as-is, with delivery and warehouse as keys.
For the form there is also a binary check that validates at least one carrier exists in the given province with a loadable price or is_free. Without state_code it returns true for compatibility with tenants without the feature; with state_code it is what activates the "not available in your province" banner when it returns false.
Two minor helpers complete the checkout module. One resolves a specific carrier from the user's choice when building the order, validating that it is available and that it is manual or from the indicated province. The other resolves the carrier price in the main unit for a given currency, returning zero if free or if the currency has no loaded price, leaving it to the caller to decide what to do with that.