troydavisson/phrets
A PHP client for the RETS protocol used by real estate MLS servers, making it simple to authenticate, query listings, download photos, and pull metadata from RETS feeds through a clean, object-oriented API.
Install and gate it
composer require troydavisson/phrets
Requires ext-curl (and SimpleXML for metadata parsing). Last release was July 2023, so pin an explicit version in composer.json, confirm it installs cleanly on your PHP version before writing app code, and add a composer outdated check to your CI routine — this is a maintenance-mode library.
Credentials you will actually need from the MLS
Not just URL/user/pass. Ask for: the login URL (https://.../rets/login), the RETS version to negotiate (1.8 or 1.7.2), a User-Agent string they have whitelisted, a User-Agent password (required on 1.7+), whether HTTP Basic/Digest auth is layered on top, and which Resource/Class names you are licensed for (Property/RE_1, etc.). Getting the User-Agent wrong produces confusing 401s that look like bad passwords.
First boot
$config = new \PHRETS\Configuration;
$config->setLoginUrl(config('rets.login_url'))
->setUsername(config('rets.username'))
->setPassword(config('rets.password'))
->setRetsVersion('1.8')
->setUserAgent(config('rets.user_agent'))
->setUserAgentPassword(config('rets.user_agent_password'));
$rets = new \PHRETS\Session($config);
$rets->Login();
Look at metadata before you look at listings. Every hour spent in GetMetadata('METADATA-RESOURCE'), METADATA-CLASS, and METADATA-TABLE saves a day of guessing field names. MLS field names are non-standard (L_Status, LM_Char10_15), so dump the table metadata for your class and keep it as a reference file. Only then write your first DMQL query.
First use case: a rets:probe Artisan command that logs in, prints GetMetadataTypes(), then pulls 5 listings:
$results = $rets->Search('Property', 'RE_1', '(ModificationTimestamp=2024-01-01+)',
['QueryType' => 'DMQL2', 'Format' => 'COMPACT-DECODED', 'Limit' => 5, 'Offset' => 0]);
dump($results->getTotalResultsCount(), $results->toArray());
In Laravel, register the Session as a singleton in a small service provider and read credentials from config/rets.php backed by env vars — never hardcode them in the wrapper.
Wrap the session, don't sprinkle it. Bind PHRETS\Session as a singleton, then put all calls behind a RetsClient/ListingRepository interface. This is what makes the rest of your app testable and is your escape hatch when you eventually migrate to RESO Web API.
Metadata as a cached schema. Run a nightly rets:metadata command that stores Resource → Class → Table definitions (and lookups) in a database table or cache with a long TTL. Metadata calls are slow and heavy; calling GetMetadata('METADATA-TABLE', ...) per request will get you throttled. Build a per-class field map from the cached metadata so your sync jobs can translate system names to Eloquent columns without hitting the server.
Paginate deliberately. Always pass Limit and Offset, always use COMPACT-DECODED, and always inspect getReturnedResultsCount() — an empty page means stop, not "retry forever". getTotalResultsCount() is your progress bar. Request only the columns you need with Select to cut payload size dramatically on photo-heavy MLSs.
Incremental sync beats full sync. Track a ModificationTimestamp watermark per Resource/Class. Query (ModificationTimestamp=YYYY-MM-DDTHH:MM:SSZ+), and overlap the watermark by a few minutes on each run to absorb clock skew and in-flight transactions. Never use Offset as your sole cursor for very large datasets — it is slow and, on some servers, silently unsupported.
Push the work to queues. A full sync is hours of I/O. Structure as: SyncMetadataJob → SyncListingsChunkJob(offset) that re-dispatches itself → FetchPhotoJob(listingId). Use RateLimited middleware or Redis::throttle to match the MLS's concurrency cap, and $schedule->command('rets:sync')->hourly()->withoutOverlapping().
Photos are a separate pipeline. GetObject($resource, $class, 'Photo', $ids, '*') accepts comma-separated IDs, which is the only way to make photo backfill tolerable. Iterate the returned BaseObject models, read getContentId()/getObjectId() to order them, and stream content to Storage::disk('s3') keyed by listing + object ID so re-runs are idempotent. Store the mime type; some servers omit it.
Error handling per reply code. Catch PHRETS\Exceptions\RetsException (and the MLS, Search, Object, Metadata subclasses) and inspect the RETS ReplyCode/ReplyText. Distinguish "server busy, back off" from "your query syntax is wrong" from "credential expired" — retry only the first. On repeated timeouts, rebuild the session and log in again.
Testing. Because the real server is slow, live, and rate-limited, fake the repository interface in tests and keep a fixture set of recorded toArray() payloads. Reserve real-server calls for a manual smoke command.
Session per process, always call Disconnect() in a finally.Configuration so multi-clause queries survive.Limit, ignore Offset, or reject Count-only searches. Verify with a probe script on day one; don't assume the spec.Search with a huge Limit loads everything into the result collection. Keep Limit modest (a few hundred), stream to the DB, and free the result between pages.ModificationTimestamp comparisons made with local time silently skip records.GetLastRequest(), GetLastResponse(), GetLastResponseHeader(), GetLastResponseBody() turn a silent failure into a readable DMQL/HTTP exchange. Wrap these into a rets:debug --query="..." command — it will be your most-used tool. Scrub credentials before logging.Session behind your wrapper so you can drop it fast.How can I help you explore Laravel packages today?