Thirteen MLS Boards, Three Transports: Building a Listing-Data Layer That Fed a Media Business
Part 5 of the Spotlight Media Group series. Read the overview first for the method and its limits. This repository is private with no 15-year commit history, so everything below is reconstructed from the code. Code samples are illustrative reconstructions, and I've left out credentials, hosts, and customer names.
A real estate photo business lives and dies on one fact: the listing already exists somewhere else. The address, price, bedrooms, agent, brokerage, and often the photos are sitting in a Multiple Listing Service (MLS), a regional database that every agent in a market pays to access. If your platform can read that data, you can build a tour for a listing before the agent has typed a single field. That idea turned out to be one of the most valuable things this platform did, and also one of the hardest to keep running.
This post covers how the MLS layer was designed, how it evolved across three different data transports, and the production issues that follow when you depend on thirteen different organizations' interpretation of "a listing".
Why the business needed it
Two product lines depended on listing data:
Concierge, the done-for-you marketing tier. Agents or entire brokerages subscribed, and the platform automatically built a tour, a property microsite, and marketing assets for each new listing, without anyone placing an order.
Social marketing (the Social Hub and later Social Compass, posts 6 and 7). Agents could post "my newest listings" to their networks, and offices could post their whole inventory.
Both need the same thing: a trustworthy, reasonably fresh copy of listing, agent, and office data, mapped into the platform's own tours, users, and brokerages.
The abstraction: a provider factory over an abstract base class
The center of the design is an abstract mlsprovider base class and a factory that maps a numeric provider ID to a concrete adapter. I count thirteen adapters in the factory, covering boards in Utah, Nevada, Colorado, California, Connecticut, New Jersey, Louisiana, and Michigan.
// ILLUSTRATIVE RECONSTRUCTION of the pattern
function providerFactory(int $id): ?MlsProvider {
return match ($id) {
4 => new ParkCityMls(),
5 => new WasatchFrontMls(),
10 => new LasVegasMls(),
12 => new WashingtonCountyMls(),
// ... nine more
default => null,
};
}
The base class owns everything that is common: creating or updating users and brokerages, creating tours, deduplicating on MLS ID, downloading and de-duplicating media, uploading photos to S3 through the same pipeline as everything else, and emailing new users. Each adapter supplies only what is different: credentials, endpoints, the mapping from the board's field names to the platform's, and the quirks.
That split is the right one, and the reason it held up for a decade. The per-board code is mostly data.
The mapping layer
Every board names the same concept differently. The base class holds a list of the platform's tour columns, and each adapter declares a mapping from its own columns to those, per resource and class (residential, commercial, land, and so on). When a listing arrives, a generic routine walks the map. If you ever have to integrate a new board, you write a mapping table and a few overrides, not a new pipeline.
Three transports, one interface
The most interesting history is in how the data arrived. The base class contains code for three separate transports, and different boards used different ones depending on what that board offered when it was onboarded:
FTP feeds. Some boards drop a data file (and sometimes a media archive) on an FTP server on a schedule. The code connects, fetches the file, parses it as CSV or XML, and downloads or rsyncs media separately. Media file names have to be parsed to work out which listing and sort order each photo belongs to.
RETS. The Real Estate Transaction Standard, the long-time industry protocol, accessed via the open-source PHRETS library. It's a session-based, metadata-driven query protocol: log in, ask for resources and classes, search with a query language, page through results.
RESO Web API. The modern OData/REST replacement. A client class dated January 2021 wraps a RESO library, and its comments quote the board's own documentation, including a 200-records-per-request limit and the
$top/$skip/$orderbypaging pattern. Adapter methods carry// reso updatemarkers where RETS calls were swapped for the new API.
So the codebase captures an industry migration in real time: FTP, then RETS, then RESO, with the oldest still running for boards that never moved. The 2021 RESO client is the one dated header in this layer; the RETS and FTP code predates it.
The data model: a local mirror per board
Rather than call the board's API on every page view, the platform mirrors listing data into its own MySQL databases, one database per board. The base class has methods to build a database from RETS metadata (tables named resource_class, with columns generated from the board's own field list), import lookup values, import all records, and then update incrementally. Pages that need a listing, an agent's active listings, or an office's inventory query the local mirror.
That decision has the usual trade: pages are fast and survive board outages, and you now own freshness, schema drift, and storage.
Where freshness was expensive
Reading the incremental update, I see a design that worked and a cost I'd flag:
The update searches every class with a "UID is 0 or greater" query, which means all records, then loops through them and filters on the modification timestamp in application code, keeping only rows changed in the last three hours.
For each kept row it checks whether the listing is already in the local table and does an update or insert.
At the top, it raises the database server's global
wait_timeoutto eight hours, from application code, so a long sync doesn't lose its connection.
The mirror stays right, but the work done is proportional to the size of the board, not to the number of changes. A server-side filter on the modification timestamp (which RETS and RESO both support) would cut that to the delta. I don't know whether a board's server didn't support that when this was written or it was a case of "make it work first". It's exactly the kind of thing I'd put on the list for an audit.
The other place memory can matter: the paged RETS query helper accumulates every returned row into one in-memory array before returning, looping on the "max rows reached" flag. For a big class that's a lot of memory in a process whose limit was set by ini_set, which connects back to the theme of post 4.
From listing to tour: the Concierge build
When the sync finds a listing for a subscribed agent or brokerage, the base class's createTour path turns it into a real tour, and this is where the integration meets the rest of the platform:
Deduplicate. Look up whether a tour already exists for that board and MLS ID. If so, update it.
Create the tour with the mapped fields, flagged as a concierge tour, with preview active and the S3 flag already set.
Create a zero-dollar order with a line item for the tour type, so the tour is a first-class citizen of billing, reports, and the agent's dashboard instead of a special case.
Create the property microsite (a per-listing subdomain).
Pull the listing's photos by URL, run them through the same photo pipeline described in post 2, and remove media that has since disappeared from the listing.
Step 3 is one I'd call good design that looks like a hack. By representing a free, automated tour as a normal order at a price of zero, the code reused invoicing, access control, reporting, and the whole admin UI without any "automated tour" branch. The cost is that "zero-dollar order" now has to be filtered out of revenue analysis forever.
The batch that drives all of this (the concierge build() method, with its watcher lock from post 4) does three loops: refresh the mirrors for each board, then for each subscribed brokerage run the board's office sync, then for each subscribed agent run an agent-listing sync, and finally handle a special "pull from MLS" tour type, where an agent just types an MLS number into a tour and the platform fills in the rest.
Production issues you inherit when you depend on boards
No two boards agree. The same concept (status, property type, sqft, acreage) arrives in different formats. Price needed explicit decimal normalization, acreage was reformatted, and full state names are converted to abbreviations. Every one of those is a bug someone hit.
Boards change their credentials, endpoints, and rules without telling you. In the adapter code, the failure path for a board's import is an email to the developer saying the import failed, with the exception text. That was the monitoring system for years.
Rate and page limits are policy, not technology. The RESO board documents a 200-record page; RETS adapters set a configurable query limit. Sync jobs are shaped by what the board will tolerate.
Photos are the expensive part. A listing is a few kilobytes of text and 30 images. Media download, de-duplication, re-hosting, and cleanup dominate cost and failure rates, which is why that path reuses the S3 pipeline.
Licensing is a gate on what you can show. MLS data comes with rules about display and retention. The mirror-per-board design makes it possible to scope access per board.
The sync service I didn't write
The repo also contains a Node.js MLS sync application under mls-sync/. I want to be exact: that is OpenReSync, an open-source project by another author. It replicates data from RESO Web API sources (such as Trestle, MLS Grid, or Bridge Interactive) into MySQL or Solr and provides a local status website. In this platform it was a later addition to modernize that part of the data flow, running on a pinned old Node version. The installation includes an example configuration only; its production source configuration isn't in the repo.
I mention it for two reasons. It's honest about what I built and what I adopted. And it's the right call to adopt: syncing a RESO feed is a solved problem, and the right move is to run a well-built tool instead of extending a 15-year-old adapter hierarchy.
What I'd keep, and what I'd change
Keep:
An abstract provider with data-driven adapters. New boards cost days, not weeks.
A local mirror so customer pages don't depend on a third party's uptime.
The zero-dollar order for automated work, so it participates in everything downstream.
Routing listing photos through the exact same pipeline as photographer uploads.
Change:
Server-side delta queries on modification timestamps instead of full pulls with client-side filtering.
Stream rows instead of accumulating whole result sets in memory.
One transport interface (FTP, RETS, and RESO behind the same "fetch changes since T" contract) with per-board configuration, not per-board code.
Real alerting instead of an email to one person, plus a freshness metric per board (age of newest record).
Never alter global database settings from application code; set per-session limits.
How I'd approach this today, with AI
As with the rest of the series: all of this integration code was written by hand, before AI coding tools were available, and the only AI involvement in the project was the 2026 move from IIS to Ubuntu. What follows is how I'd use an assistant on this layer now, which happens to suit it well, if you're careful:
Mappings are data. A model can diff two adapters' field maps and point out which fields are missing, misnamed, or mapped to the wrong column. That's the sort of tedious cross-checking that humans skip.
Characterize before replacing. To move a board from RETS to RESO, record a sample of real listings (anonymized) as fixtures, have the assistant write tests asserting the existing mapper's output, and only then change the transport. The fixtures, not the model, are the safety net.
Keep credentials out of the loop. This is the area of the codebase where credentials were most scattered, and the first AI-assisted task should be finding and extracting them to configuration, not "improving" the adapters. The assistant doesn't need real credentials to do that, and you shouldn't give them to it.
Evidence appendix
Provider factory with thirteen boards:
repository_inc/classes/class.mls.php.Abstract base, tour columns, user/brokerage creation, media handling, S3 finalize:
class.mlsprovider.php.RETS connect/query/paging, per-board database build, incremental update with a three-hour window and global
wait_timeout:class.mlsprovider.php.FTP feed and media-download paths:
class.mlsprovider.php(ftpFetchFeed,ftpFetchMedia,processRSYNCMedia), board adapters such asclass.pcmls.php.RESO client (header dated 01-14-2021, OData paging notes):
class.reso.php, and// reso updatemarkers inclass.wfrmls.php.Concierge batch, zero-dollar orders, microsite creation:
class.concierge.php,class.mlsprovider.php(createTour).Third-party sync tool:
mls-sync/(OpenReSync, ownREADME.md).
Comments
No comments yet — be the first to share your thoughts.
Leave a comment
Your comment will be reviewed before it appears publicly.