The merge contract
Two sources report the same show. They'll disagree on almost everything except the fact of the show itself: the venue's spelling, the time zone, which details they bother to include. The rules, in order:
- Every row is normalized first into one shape: a sortable date and display date guaranteed, venue, city and state trimmed and validated, ticket links split per provider, coordinates parsed, sold-out detected.
- The first row seen wins the slot, keyed by
date | city | venue. - Later duplicates fill gaps, never overwrite. Ticketmaster knows the price, Bandsintown knows the coordinates, so the merged row knows both.
- The source badge upgrades to the richer feed when Ticketmaster joins a Bandsintown row, and both ticket links are kept so a fan can buy from either.
- Rows with no city and no venue are dropped. Garbage in, nothing out.
// The loose key: sources disagree on venue spelling and time zones,
// but they rarely disagree on the date and city of a show.
function tourLooseMatchKey(array $row): string {
if ($row['city'] === '' && $row['venue'] === '') return ''; // nothing to key on: dropped
return $row['ymd'] . '|' . slug($row['city']) . '|' . slug($row['venue']);
}
Simplified sketch. The production functions are in src/ on GitHub.
War stories from real data
Every calendar site eventually learns these. This pipeline learned them the expensive way, and each one is in the demo above:
- The venue that was the artist
- Bandsintown sometimes sends a show whose "venue" is just the DJ's name echoed back. The pipeline checks the site's artist roster and blanks a venue that's really a person. Without that, artist pages grew phantom venues named after the artist.
- The coordinate 999.0
- Both feeds use
999(and0) to mean "unknown location". Those parse as perfectly valid numbers, and one of them puts a show in the Gulf of Guinea. Sentinels become "no coordinates", because wrong coordinates are worse than missing ones. - The date with no year
- Curated data says "Mar 15" and means the next March 15, which is next year if you read it in September. The year is inferred and rolled forward, so a yearless date never comes out in the past.
- The show that already happened
- Past dates are pruned when the cache is written and when it's read, not just when the page renders, and the expiry step reports exactly what it removed.
Cache design
- Versioned filenames (
<artist>.v5.json). The row shape has evolved five times. Bumping the version means old files are simply ignored and cleaned up lazily, instead of everything being invalidated mid-deploy. - One bulk read per request. All cache files are loaded in a single pass, newest wins. On shared hosting, that turned dozens of per-artist file checks into one.
- Stale-while-revalidate. A fresh cache is served first; on a miss, a stale one can be served before paying for a network fetch. A slow day at Bandsintown never blanks an artist page.
- 12-hour freshness. Tour dates change, but not by the minute.
Design decisions
| Decision | Why |
|---|---|
| Thin endpoint, all logic in one library | The pipeline can be tested without any HTTP at all. |
| Guards around every site-specific function | Outside the host site the library degrades to defined fallbacks instead of crashing. |
| First wins, later rows fill gaps | Never lose a field that any source provided. |
| Loose key: date | city | venue | Sources disagree on spelling and time zones, rarely on the date and city. |
| Sentinel coordinates rejected | Wrong coordinates are worse than missing ones. |
| Zero dependencies | No Composer; drops into shared hosting and works with curl or PHP streams. |
What changed from production
The repo is production code with the private parts removed, and it says so plainly. API keys, the Ticketmaster key holder and affiliate wrapper, the site's artist roster and the rendering layer stay private. In their place are placeholders and guarded fallbacks: with no Ticketmaster key, that source simply contributes zero rows. Everything else is the production code verbatim.
Try it locally
No network, no API keys, no database:
php demo/run.php # the fixture run, end to end (what the demo above re-enacts)
php tests/run-tests.php # the self-test that CI runs
Adding a source is one fetch function that returns rows in whatever shape the feed uses. Normalize them into the canonical row, then merge. No registry and no interface to implement.
From the repo's README. PHP 8.0+, with curl or the HTTP stream wrapper. The demo on this page is a JavaScript re-enactment of the same rules so it can run in your browser; the real pipeline is PHP.
Related projects
Frequently asked questions
What does tour-feed-pipeline combine?
It normalizes curated dates, Bandsintown events, and Ticketmaster events into one row format, then merges, sorts, caches, and prunes the results.
How does the pipeline deduplicate shows?
It keys a show by date, city, and venue. The first row keeps its slot; later duplicates fill missing fields without overwriting values already present.
How are old shows handled?
Past events are filtered from upcoming results and pruned from persisted cache data on both reads and writes.
Does the demo call ticket or event APIs?
No. The repository demo runs on local fixtures without a network, API keys, or a database. The repository’s Ticketmaster adapter is a stub until configured for a real integration.
Messy data? That's the fun part.
Bryan Hamilton is a full-stack web engineer with 25+ years in PHP and JavaScript, available immediately for remote, hybrid or on-site roles in the NYC metro area.