Skip to main content

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.

Settings queries

The service resolves two mirror queries, one per category: the list of province rates and the list of pickup points. Both come with their prices in eager loading and sorted by title. They feed the UI tables.

Writes

Writes from the UI are four.

Creating or editing a province rate triggers an upsert that receives a DTO with the data validated by Livewire, auto-generates the title from the state name, and persists the carrier along with its prices inside a transaction.

Creating or editing a pickup point follows the same pattern, but leaves the description empty and saves the address and schedule in the address JSON.

For lifecycle management there are two pairs of operations: a toggle of the is_available flag to suspend a carrier without deleting it, and a delete that performs a hard delete without checking whether pending SNs remain — that validation is in the operator's hands.

Inspection helpers

The service exposes three helpers that the UI and checkout consume to answer binary questions or catalog active provinces.

tenantHasProvinceCarriers() reads directly from canAccessFeature('shipping_by_province'): it is a wrapper around the feature flag, not a carrier query.

tenantHasAnyProvinceRate() queries the DB via the provinceRates() scope, so it counts rows with type = delivery and a non-null state_code.

getActiveStateCodes() returns the distinct state_code values where there is at least one active carrier (of any type, delivery or warehouse). It is what the shipping form in checkout uses to filter the province dropdown and show only the ones the tenant dispatches to.

Dashboard settings

Carrier management lives in two different places depending on the category. Manual carriers are administered from a legacy CRUD that touches the DB directly; province rates and province pickup points are administered from a Livewire UI that goes through the service.

For manual carriers — those without state_code — the controller App\Http\Controllers\Dashboard\Settings\ShippingCarrierSettingsController exposes only index, store, and destroy at /dashboard/settings/shipping/carriers, gated by the tenant feature physicals_goods.

Although the CRUD is known as "the manual one", it accepts both home delivery carriers and pickup points: the only condition to enter through this path is that the carrier is not tied to a province.

store is a bulk upsert with "full set" management: it receives a complete array of carriers, performs an upsert by id, and deletes any carrier not included in the payload. Validation is centralized in App\Http\Requests\Shipping\CarriersRequest, which validates type against CARRIER_TYPES (accepts both delivery and warehouse).

There are two security details. The controller does not accept country_code, state_code, or address: those fields are exclusive to the province flow and do not enter through this path.

destroy() aborts with 403 if the carrier has a non-null state_code, protecting province rates and pickup points from being accidentally deleted through the legacy UI. The view mounts an inline editor that allows adding and deleting carriers, marking is_free, and loading a price per active currency, all on one screen.

For province rates and pickup points the flow is completely different. The UI lives in the Livewire component ProvinceShippingSettings, mounted inside the general dashboard settings and calling directly to the service. The component exposes separate modals for creating/editing provinces and pickup points, with tables listed by type. When the shipping_by_province feature is active, in addition to enabling the CRUD, checkout enters "mandatory province" mode (see below).

One detail to keep in mind: there is no uniqueness constraint in the DB nor validation in the form on the combination [tenant_id, state_code, type], so two province rates for the same province can be loaded. Checkout would show both. It is up to the tenant to avoid duplicates from the UI.

Carrier selection in checkout

There are two checkout Livewire components with different carrier handling. The current one integrates the province feature; the legacy one ignores it.

The current checkout lives in CheckoutV2 and its sub-component ShippingForm, which consume ProvinceShippingService directly.

On mount, they read whether the tenant has province carriers. If it does, they force the country dropdown to the tenant's country and filter the province dropdown to show only those where there are active carriers.

The state_code that feeds the carrier query does not come from the request or the session: it is taken from the user's last saved shipping_information. As long as the user has no valid saved state, the carrier list comes back empty and checkout forces a selection before proceeding.

The blade consumes two computed properties of the component: one feeds the UI grouped by type, and the other decides whether to render the toggle between delivery and warehouse — the toggle appears only when there are available carriers of both types for that province. When the user makes their selection, the component dispatches an event to the parent with id, amount, label, and type. The selection stays in memory in checkout and is persisted only when building the order, where it travels as part of the payment payload.

The legacy checkout lives in ShippingChoice and is only used by tenants that render the cart without CheckoutV2. It calls the service always with state_code as null and only sees manual carriers.

If a tenant with the province feature active ends up rendering this component, province rates and pickup points would not be shown.

How the carrier feeds the snapshot

ShippingNoteService::prepareShippingInformation is the helper that builds the snapshot the carrier leaves in order.shipping_information. For warehouse the description is already resolved from the address and schedule, so the SN does not depend on the original address JSON. The snapshot structure — which fields travel, how it is composed for delivery and warehouse, what is trimmed when copying it to the SN — lives in Data model.

Changing the address post-purchase leaves already-created SNs intact because the snapshot is copied into each one at the moment of its creation. Detail of the operational consequence in Lifecycle — Post-purchase address change.

Edge cases

Currency without a loaded price. The carrier silently disappears from checkout, without an error. To detect it, look at the list of carriers offered to the user per active currency.

Carrier deleted with SNs in progress. The hard delete passes without friction; SNs survive with their snapshot, but the cycle generation job for annuals logs a skip due to carrier not found and stops generating future SNs for that order. If the carrier only needs to be paused, deactivate it with is_available = false instead of deleting (valid for province rates and pickup points; manual carriers do not expose that flag from the UI).

Multiple province rates for the same province. No uniqueness constraint. Checkout shows all of them and the user sees two identical options. It is the tenant's responsibility to avoid duplicates from the UI.

Tenant changes country. The country_code of carriers is not updated automatically when the tenant's signUpIntent->country_code changes. Existing province rates keep pointing to the old country and new carriers are created with the new country, causing inconsistencies until the tenant repairs them manually.

References

FileRole
app/Domains/Commerce/Models/ShippingCarrier.phpModel, scopes, helpers
app/Domains/Commerce/Traits/ShippingHasPrices.phpPolymorphic priceMorph and price writes
app/Domains/Commerce/Services/ProvinceShippingService.phpProvince rates and pickup points: queries, writes, checkout grouping
app/Domains/Commerce/DataTransferObject/ProvinceInput.phpDTO for creating/editing province rates
app/Domains/Commerce/DataTransferObject/PickupPointInput.phpDTO for creating/editing pickup points
app/Http/Controllers/Dashboard/Settings/ShippingCarrierSettingsController.phpLegacy manual carrier CRUD
app/Http/Requests/Shipping/CarriersRequest.phpLegacy CRUD validation
app/Http/Livewire/CheckoutV2/CheckoutV2.phpCurrent checkout, integrates the province feature
app/Http/Livewire/CheckoutV2/ShippingForm.phpSub-component with the grouped carrier list
app/Http/Livewire/Cart/ShippingChoice.phpLegacy checkout, no province support
app/Http/Livewire/Dashboard/Settings/ProvinceShippingSettings.phpSettings UI for province rates and pickup points
resources/views/livewire/dashboard/settings/province-shipping-settings.blade.phpProvince settings view
X

Graph View