From tracking number to status timeline
Hyvä's stock order history shows a rough order status but no tracking number and no delivery timeline. This article shows how to load shipment and track data efficiently via GraphQL, how a carrier code to tracking URL mapping looks as a view model, and how an Alpine component turns that into a readable status timeline. Rounded off with partial deliveries, caching considerations, and the most relevant edge cases.
Table of Contents
- 1. What Hyvä Ships Out Of The Box, And Where The Gap Is
- 2. The Magento Data Model: Shipment And Track Entities
- 3. A GraphQL Query For Order History With Shipments And Tracking
- 4. Performance: Avoiding N+1 And Controlling Query Cost
- 5. Building The Status Timeline As An Alpine Component
- 6. Carrier Code To Tracking URL: The View Model Pattern
- 7. Private Content And Caching In The Customer Account
- 8. Handling Partial Deliveries And Multi Shipment UI
- 9. Edge Cases: Missing Tracking Numbers, Carrier Free Shipments, Delayed Status
- 10. Summary
- 11. FAQ
1. What Hyvä Ships Out Of The Box, And Where The Gap Is
Hyvä's stock customer account ships a lean order history: order number, date, a status label, and a link to the detail view. That covers the listing itself, but it says nothing about where a shipment actually is right now. A customer who wants to know whether a package has reached the carrier still has to wait for a shipment confirmation email or copy the tracking number into an external portal by hand.
The stock Magento\Sales\Block\Order\History block, and its Hyvä counterpart, surfaces the aggregated order status such as processing or complete, but never connects it to the individual shipment records. That missing link, tracking number, carrier, and a visual status model, is exactly what this article adds.
This is deliberately scoped away from topics like the reorder button, dashboard tiles, or the order success page, which are already covered elsewhere. What follows is strictly about the detail view of an existing order and how far it has actually traveled.
2. The Magento Data Model: Shipment And Track Entities
Shipment tracking in Magento rests on two entities. An order can have several Shipment records for partial deliveries, and each shipment can reference multiple Track entries, since a single package could theoretically be split across carriers or parcels. The track object carries the actual tracking number, the carrier code, and an optional display title.
For the frontend, the relevant methods are ShipmentTrackInterface::getCarrierCode(), getTrackNumber(), and getTitle(). The carrier code is not a URL, it is an internal identifier such as dhl, ups, fedex, or custom for manually entered carriers. That raw value needs to be mapped to a clickable tracking URL in the frontend, covered in the view model section below.
It is also worth noting that a shipment only exists once the merchant has actually packed and shipped the order in the admin. Until then there is simply no track record, and the frontend timeline needs to treat that state as a normal step, not as an error condition.
3. A GraphQL Query For Order History With Shipments And Tracking
The stock customer.orders query already exposes nested tracking data through its shipments field, but Hyvä's default theme queries it only superficially. For the detail view it pays off to write a dedicated, lean query that loads exactly the fields the timeline and the tracking links actually need.
Pagination happens at the order level via pageSize and currentPage, not at the shipment level, since a single order rarely carries more than a handful of shipments in practice. The query should be scoped differently per page: the list view does not need tracking details, the detail view does.
query CustomerOrderTracking {
customer {
orders(pageSize: 5, currentPage: 1) {
total_count
page_info {
current_page
page_size
total_pages
}
items {
number
order_date
status
shipments {
number
tracking {
carrier
title
number
}
}
}
}
}
}
4. Performance: Avoiding N+1 And Controlling Query Cost
Firing a separate detail query against customer.orders for every row in the order history to fetch tracking data creates a textbook N+1 problem: twenty orders on a page mean twenty extra round trips to the GraphQL endpoint. The fix is to load tracking data in the same query as the order list from the start, but with a reduced field selection.
A workable compromise is two tier loading: the list view only asks for number, status, and a lightweight shipments { tracking { carrier } } to render a small shipping icon per row. The full tracking number and status timestamps are only fetched once a customer actually expands a row, triggered via Alpine's x-init with a targeted fetch instead of re-running the full order history query.
On the server side it also helps to inspect the resolver behind shipments: by default Magento loads shipments through a collection object that, under an unfavorable configuration, can end up fetching per order individually. Enabling the query log via bin/magento dev:query-log:enable quickly shows whether each row triggers one SQL query or several.
query OrderShipmentDetail($orderNumber: String!) {
customer {
orders(filter: { number: { eq: $orderNumber } }) {
items {
number
shipments {
number
tracking {
carrier
title
number
}
}
}
}
}
}
5. Building The Status Timeline As An Alpine Component
The timeline covers three to four steps: placed, processing, shipped, delivered. Magento does not expose a ready made timeline status, only the aggregated order status as a plain string. The mapping has to be built by hand, ideally inside a small Alpine component that takes the order status and the shipment count as input.
The aggregated status complete does not necessarily mean delivered, because Magento sets it as soon as every item is invoiced and shipped, regardless of the actual delivery state at the carrier. Showing a genuine delivery confirmation requires either a carrier tracking API integration or a manually maintained status field, since Magento itself has no automatic delivery status.
<div x-data="orderStatusTimeline({
orderStatus: '<?= $block->escapeJs($order->getStatus()) ?>',
shipmentCount: <?= (int) count($order->getShipmentsCollection()) ?>
})" class="mt-6">
<ol class="flex items-center w-full">
<template x-for="(step, index) in steps" :key="step.key">
<li class="flex-1 relative">
<div class="flex items-center">
<div :class="step.done ? 'bg-primary text-white' : 'bg-gray-200 text-gray-500'"
class="w-8 h-8 rounded-full flex items-center justify-center text-sm">
<span x-text="index + 1"></span>
</div>
<div x-show="index < steps.length - 1" class="flex-1 h-0.5"
:class="step.done ? 'bg-primary' : 'bg-gray-200'"></div>
</div>
<p class="mt-2 text-sm" :class="step.done ? 'font-semibold text-gray-900' : 'text-gray-400'"
x-text="step.label"></p>
</li>
</template>
</ol>
</div>
<script>
function orderStatusTimeline({ orderStatus, shipmentCount }) {
return {
steps: [],
init() {
const map = {
pending: 0,
processing: shipmentCount > 0 ? 2 : 1,
complete: 2,
closed: 3,
canceled: -1
};
const reached = map[orderStatus] ?? 0;
const labels = ['Placed', 'Processing', 'Shipped', 'Delivered'];
this.steps = labels.map((label, index) => ({
key: label,
label,
done: index <= reached
}));
}
};
}
</script>
<?php $hyvaCsp->registerInlineScript(); ?>
6. Carrier Code To Tracking URL: The View Model Pattern
Since a carrier code alone does not produce a clickable URL, a small translation layer is needed. A view model implementing ArgumentInterface is the right place for this, because it can be injected cleanly into the template via layout XML without introducing a dedicated block.
The mapping itself should stay configurable, so new carriers can be added without a code deploy. In practice, a base array in the view model combined with an optional override through system.xml has worked well, useful for cases where a merchant suddenly needs a regional carrier such as Hermes or Evri.
For unknown carrier codes, the method has to return a sensible fallback, such as a search combining the tracking number and carrier name, instead of showing no link at all. That keeps customers from being left without any point of reference for less common carriers.
<?php
declare(strict_types=1);
namespace Mironsoft\OrderTracking\ViewModel;
use Magento\Framework\View\Element\Block\ArgumentInterface;
/**
* Maps carrier codes to clickable tracking URLs for the order history.
*/
class CarrierTrackingUrl implements ArgumentInterface
{
/**
* Base mapping of Magento carrier code to URL pattern with a placeholder for the tracking number.
*
* @var array<string, string>
*/
private const CARRIER_URL_MAP = [
'dhl' => 'https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=%s',
'ups' => 'https://www.ups.com/track?tracknum=%s',
'dpd' => 'https://tracking.dpd.de/status/de_DE/parcel/%s',
'hermes' => 'https://www.myhermes.de/empfangen/sendungsverfolgung/sendungsinformation/#%s',
'fedex' => 'https://www.fedex.com/fedextrack/?trknbr=%s',
];
/**
* Builds the tracking URL for a given carrier code and tracking number.
* Returns a search fallback when the carrier code is unknown.
*
* @param string $carrierCode
* @param string $trackNumber
* @return string
*/
public function getTrackingUrl(string $carrierCode, string $trackNumber): string
{
$pattern = self::CARRIER_URL_MAP[strtolower($carrierCode)] ?? null;
if ($pattern === null) {
return sprintf(
'https://www.google.com/search?q=%s',
urlencode($carrierCode . ' tracking ' . $trackNumber)
);
}
return sprintf($pattern, urlencode($trackNumber));
}
}
7. Private Content And Caching In The Customer Account
The entire customer account runs through Magento's private content mechanism and is never served from the full page cache. That means the order history, timeline included, is rendered fresh server side on every request, which is uncritical for performance but does make client side caching of the GraphQL response worth considering.
For the timeline this is actually an advantage: there is no risk of a stale FPC entry showing an outdated status, unlike product pages with prices. The data comes straight from the database on every request. To still avoid unnecessary requests, a simple in memory cache scoped to the page session, for example via Alpine.store, is preferable to refetching on every tab switch.
It is also worth double checking that the GraphQL endpoint itself is not accidentally cached by Varnish. With the X-Magento-Cache-Debug header correctly configured, it takes seconds to confirm that order history requests come back as MISS or bypass the FPC entirely.
8. Handling Partial Deliveries And Multi Shipment UI
Once an order ships in multiple packages, a single timeline is no longer enough. The right approach is to group by shipment, each with its own tracking number, carrier, and status, while the parent order status still sits at the top as a summary.
An accordion pattern works well in practice: only the first or the most recent shipment is expanded by default, and the rest can be revealed on click. That keeps the detail view readable even for orders split into five or six individual packages, without forcing the customer to scroll through a long, unstructured list.
<?php /** @var \Magento\Sales\Model\Order $order */ ?>
<div class="space-y-4">
<?php foreach ($order->getShipmentsCollection() as $index => $shipment): ?>
<div x-data="{ open: <?= $index === 0 ? 'true' : 'false' ?> }" class="border rounded-lg">
<button type="button" @click="open = !open" class="w-full flex items-center justify-between p-4">
<span class="font-semibold">
<?= $block->escapeHtml(__('Shipment %1', $shipment->getIncrementId())) ?>
</span>
<span x-text="open ? '−' : '+'"></span>
</button>
<div x-show="open" x-collapse class="p-4 border-t">
<?php foreach ($shipment->getTracksCollection() as $track): ?>
<p class="text-sm">
<?= $block->escapeHtml($track->getCarrierCode()) ?>:
<?= $block->escapeHtml($track->getTrackNumber()) ?>
</p>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
9. Edge Cases: Missing Tracking Numbers, Carrier Free Shipments, Delayed Status
Not every shipment carries a tracking number. Store pickup, certain freight deliveries, or shipments through third parties without tracking integration leave the field simply empty. The timeline has to catch this case explicitly and show a plain text notice instead of an empty or broken link, explaining that no tracking is available for this shipment.
A similar situation arises when a tracking number exists but no recognized carrier code is attached, for example with manually entered carriers using the custom code. Here the view model from the earlier section should apply the fallback described there, rather than displaying the raw tracking number unlinked and without context.
A third case involves delayed status updates: when a carrier reports no new scan for days, a plain shipped label starts to feel misleading. Without an active tracking API integration, the shop can only soften this with a timestamp hint, such as the shipping date plus the last update, so customers do not get the impression their package has been lost.
| Magento Order Status | Shipment Present? | Frontend Timeline Step |
|---|---|---|
| pending | No | Placed, payment pending |
| processing | No | Processing, picking in progress |
| processing | Yes, at least one shipment | Shipped, tracking number available |
| complete | Yes, all items invoiced and shipped | Shipped or delivered, without a carrier API only an assumption |
| closed | Yes, possibly including a return | Closed, including refund |
| canceled | No | Canceled, timeline is hidden |
Mironsoft
Hyvä theme development and Luma migration
Still running Luma, or a Hyvä theme that just doesn't feel right?
We build Hyvä themes for Magento from scratch or migrate existing Luma shops cleanly, with Tailwind CSS, Alpine.js, and none of the unnecessary JavaScript baggage.
Luma-to-Hyvä Migration
Move an existing shop to Hyvä in a structured way, without losing functionality.
Custom Theme Development
Build a custom Hyvä theme from scratch based on your design.
Performance Optimization
Improve Core Web Vitals and load times in the Hyvä frontend with purpose.
10. Summary
Order Status & Tracking
Data Model
Order, shipment, and track entities expose carrier code and tracking number, but no ready made timeline status.
GraphQL
A lean, two tier query separates the list view from the detail view to avoid unnecessary field selection.
Timeline UI
An Alpine component maps the aggregated order status and shipment count onto three to four timeline steps.
Edge Cases
Missing tracking numbers, unknown carrier codes, and delayed status all need explicit fallbacks instead of empty links.