Migrating a custom PHP storefront to Shopify on a metaobject layer
- 01Rebuilt a custom PHP storefront as a Shopify Online Store 2.0 theme, page type by page type
- 02Designed the metaobject layer that carries brand, style, colour and collection editorial outside the product model
- 03Built the browse, merchandising, structured data and custom order surfaces that read from it
Role
Lead Shopify developer, sole engineer on the theme build and catalog architecture. Working directly with the store owner, with the merchant editing the live theme in parallel through the Shopify admin. Codebase greenfield Shopify Online Store 2.0 build replacing a custom PHP storefront, maintained since January 2025.
Scope covered
- Shopify Online Store 2.0
- Metaobjects and metafields
- Liquid section and template architecture
- Structured data and Merchant Center feeds
- Alpine.js storefront interactivity
Context
For years this retailer sold luxury men's footwear from a custom PHP storefront. Traces of it are still inside the store's own content: the returns policy the merchant maintains today still sends customers to a contact-us.php page on the old site.
That storefront was not a product catalog with a cart attached. It was a browse directory. Shoppers arrived looking for a brand, a style, a colour or an occasion, and the value of the site sat in the editorial layer around the products: brand pages with logos and history, alphabetical indexes, colour directories, style guides, a custom order programme. Shopify models products, variants and collections. It models none of that.
Nothing was ported. Between January and May 2025 I rebuilt the storefront one page type at a time: homepage, brand and style and colour directories, collection and product pages, sale and lookbook pages, sitemap, policy and buying guide pages. The catalog itself moved cleanly. The structure around it had to be rebuilt as something Shopify could hold.
My role
I was the only engineer on the storefront build, working directly with the store owner. I owned the theme architecture, the catalog layer described here, the browse and merchandising surfaces, the product feed, and the configurator. The merchant handled the product import and URL redirect map in Shopify Admin.
From July 2026, a second developer took over a later batch of storefront fixes. This story focuses on the architecture and implementation I delivered.
The actual challenge
The obvious move is to make every brand a Shopify collection. It does not survive this catalog.
A collection carries a title, a description, one image and a set of products. The legacy brand pages needed a logo that is not the hero image, an editorial description separate from the SEO description, a curated list of ten shoes, lifestyle photography, video, and a flag marking which brands surface on the homepage. Four browse axes, brand and style and colour and accessory, needed the same shape, the same components, the same alphabetical indexing, and each entry pointing at the collection that holds the products.
Two platform facts shaped everything else. Shopify has no relationship between a product and a brand, only product.vendor, a plain string typed by whoever created the product. And Shopify pages are flat, so the directory tree the PHP site expressed in its URLs has no native equivalent on the other side.
The metaobject layer
I put the structure Shopify does not model into metaobjects, and kept products as products.
| Definition | What it carries | Read by |
|---|---|---|
product_brand | Title, a type discriminator, featured flag, logo image, editorial description, collection reference | Brand, style, colour and accessory indexes; collection banners; brand logo on product pages; breadcrumbs |
col_info | "About the…" copy, a top ten list, lifestyle images, video URLs, featured styles, care guidance | Collection information section |
sales_tag | A promotional tag title plus the collections it must never appear in | Six product renderers |
custom_shoes | The style, coating and toe option tree for a custom order collection | Configurator |
look_book | Shoppable editorial imagery | Lookbook |
The decision I would defend hardest is product_brand being one definition with a type field rather than four definitions. Brands, styles, colours and accessory makers are the same shape, differing only in which index they belong to. One definition means the alphabetical index, the featured rail query and the collection banner lookup are each written once and then filtered:
{% paginate metaobjects.product_brand.values by 1000 %}
{% assign product_styles = metaobjects.product_brand.values
| where: 'type', 'Shoe Styles' | sort_natural: 'title' %}
{% endpaginate %}That code is also where the cost of this architecture sits. Liquid gives metaobjects no query interface. You cannot fetch one by a field, so every read pages through all values and filters in memory, the page size has to be stated explicitly or a browse index quietly renders a truncated directory, and matching an entry to the collection it describes is a loop comparing handles until one hits. It is the right trade at this catalog's size and would not be at ten times the size.
Browse pages and the vendor join
With no key linking a product to its brand entry, the join is product.vendor matched against the metaobject title. That worked until it did not. A vendor typed with different casing in the admin silently matched nothing, because where: "title", product.vendor is an exact string comparison. Products lost their brand logo and nothing reported an error. The fix was a downcased loop with an early break, now the join used by both the media gallery and the breadcrumb trail.
The same period fixed a URL problem the migration created. Product cards across six renderers linked with | within: collection, giving one product a different URL per grid. Removing it gave every product a single canonical path, which is what redirects from the old site need to point at, and what the structured data and the Merchant Center feed emit.
The hierarchy the PHP site got from its directory structure is rebuilt from metafields: pages and collections carry breadcrumb_pages and breadcrumb_collections reference lists that the breadcrumb snippet walks, so the merchant maintains that tree in the admin instead of the theme hardcoding it.
Merchandising rules across six renderers
Promotional badges are sales_tag entries carrying the collections they must be suppressed in, so a premium brand stays out of the sitewide sale badge without touching a product.
Liquid has no shared data structure across sections, so each renderer flattens the rule into a string and parses it again per product:
assign sales_tags = sales_tags | append: tag_title | append: '||' | append: excluded_collection_handlesHandles are compared wrapped in commas, ,handle, inside ,a,b,c,, so that sale does not match winter-sale. Six surfaces run that resolution independently.
Six copies of one rule is as fragile as it sounds, and it broke in a way worth recording. In May 2026 I added per product exclusion to the collection grid. In June a routine pull of the live theme reverted that commit exactly, because the ignore rules protect templates/*.json, settings_data.json, locales and the layout, but not sections/*.liquid. The merchant's admin copy of that section was older than mine, so the sync quietly restored it. The other five surfaces kept working, which is why the bug arrived as "the badge is wrong on collection pages".
The custom shoe configurator
The custom order programme is a nested option tree per collection, style then coating then toe shape, held in custom_shoes entries and driven by a small Alpine component. When a shoe has exactly one path through the tree the dialog never opens and the shopper goes straight through. The tree resolves to one opaque option id, which is all that is handed to the external design tool. The theme never learns what that id means, which is why the handoff survived changes on the other side of it.
Result
The finished storefront runs 29 rebuilt page templates over 107 sections and 85 snippets. Five metaobject definitions now carry the browse structure that the old site expressed through its URLs, and one merchandising rule resolves consistently across six product surfaces.
The migration preserved the catalog's editorial depth while moving daily ownership into Shopify. The merchant can manage brands, browse indexes, collection storytelling, promotional rules, and custom order options as structured content instead of requesting a new template for each variation.
Takeaway
When a catalog built for browsing moves to Shopify, the products are the easy part. The hard part is the edges, brand to collection and style to index and colour to directory, because those are what the old site's URLs encoded and what the product model has no room for. Find them before building any pages, give them one metaobject definition per shape rather than one per page, and put a discriminator field on it so a fifth browse axis becomes a filter instead of a rewrite. Then count how many renderers resolve each rule you store there, because a rule read in six places is six places that can drift.