yeevy/centris-passerelle

Unofficial PHP client for the Centris® Passerelle FTP feed — parse, sync & reconcile Quebec MLS listings. Not affiliated with or endorsed by Centris or QFREB.

Maintainers

Package info

github.com/yeevy-ai/centris-passerelle

pkg:composer/yeevy/centris-passerelle

Transparency log

Statistics

Installs: 57

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.1 2026-07-05 02:13 UTC

This package is auto-updated.

Last update: 2026-07-05 03:27:51 UTC


README

Latest Version on Packagist Tests Total Downloads

Non officiel. Aucune affiliation avec Centris ou l'APCIQ/QFREB, ni endossement de leur part. Requiert une entente de diffusion valide.

Unofficial. Not affiliated with or endorsed by Centris or QFREB. Requires a valid diffusion agreement.

Français | English

Français

Client PHP open source non officiel pour le flux FTP Centris® Passerelle (données d'inscriptions MLS du Québec distribuées aux courtiers autorisés). Analyse, synchronise et réconcilie les données d'inscriptions.

Noyau PHP pur, sans dépendance à un framework : utilisable depuis une extension WordPress, Laravel, Symfony ou un simple script cron.

Comment fonctionne le flux Passerelle

  • Aucune API publique. Le courtier signe une entente de diffusion avec Centris/APCIQ et reçoit des identifiants FTP limités à ses propres inscriptions.
  • Centris dépose un instantané complet une ou deux fois par jour (pas de deltas) : les retraits se détectent par différence — une inscription présente en base mais absente du nouveau fichier est vendue, expirée ou retirée.
  • Fichiers livrés : INSCRIPTIONS.TXT (inscriptions), REMARQUES.TXT (descriptions FR/EN), PHOTOS.TXT, ADDENDA.TXT, plus des fichiers de référence (courtiers, agences, caractéristiques, municipalités).
  • Format : CSV délimité par virgules, champs entre guillemets, encodage Windows-1252, sans ligne d'en-tête, colonnes positionnelles (~150), une inscription par ligne (CRLF).

Prérequis

  • PHP 8.2+ avec l'extension mbstring
  • Une entente de diffusion Passerelle valide (ce paquet ne fournit aucune donnée)

Installation

composer require yeevy/centris-passerelle

Utilisation

use Yeevy\CentrisPasserelle\Parser\ListingsParser;
use Yeevy\CentrisPasserelle\Enums\ListingStatus;

$parser = new ListingsParser();

foreach ($parser->parseFile('/chemin/vers/INSCRIPTIONS.TXT') as $listing) {
    $listing->mlsNumber;      // « 9999999 » — clé d'upsert
    $listing->salePrice;      // 975000 (null pour les locations)
    $listing->status;         // ListingStatus::Active | ListingStatus::Sold | null
    $listing->descriptionFr;  // contient du HTML <br/>
    $listing->descriptionEn;
    $listing->latitude;
    $listing->longitude;
    $listing->dirtyHash;      // sha256 de la ligne brute — ignorez les lignes inchangées
    $listing->row;            // ligne brute complète pour les colonnes non cartographiées
}

L'analyse est paresseuse (générateur) : les instantanés volumineux ne saturent pas la mémoire. La conversion Windows-1252 → UTF-8 est appliquée automatiquement.

Chaque fichier du dépôt a son analyseur. Les fichiers de détail se joignent aux inscriptions par numéro MLS ; les fichiers de référence par leurs propres codes :

Fichier Analyseur Contenu
REMARQUES.TXT RemarksParser Descriptions FR/EN
PHOTOS.TXT PhotosParser Photos ordonnées, URL media.ashx
ADDENDA.TXT AddendaParser Addenda en segments à réassembler
CARACTERISTIQUES.TXT FeaturesParser Caractéristiques codées
DEPENSES.TXT ExpensesParser Taxes et dépenses
RENOVATIONS.TXT RenovationsParser Rénovations déclarées
LIENS_ADDITIONNELS.TXT AdditionalLinksParser Visites virtuelles, vidéos
VISITES_LIBRES.TXT OpenHousesParser Visites libres planifiées
UNITES_DETAILLEES.TXT UnitsParser Unités (principale, logements, intergénération)
PIECES_UNITES.TXT RoomsParser Pièces par unité (dimensions, revêtements)
MEMBRES.TXT BrokersParser Courtiers (clé : code courtier)
FIRMES.TXT FirmsParser Agences (clé : code firme)
BUREAUX.TXT OfficesParser Bureaux (clé : code bureau)
use Yeevy\CentrisPasserelle\Parser\PhotosParser;

foreach ((new PhotosParser())->parseFile('/chemin/vers/PHOTOS.TXT') as $photo) {
    $photo->mlsNumber;      // clé de jointure
    $photo->sequence;       // ordre d'affichage
    $photo->categoryCode;   // FACA = façade, CUI = cuisine, SDB = salle de bain…
    $photo->url;            // https://mediaserver.centris.ca/media.ashx?id=…
}

Positions de colonnes

Les positions livrées avec le paquet sont observées par la communauté et peuvent varier selon la version de votre entente. Vérifiez-les contre la documentation PDF Passerelle fournie avec votre entente, puis surchargez-les au besoin :

use Yeevy\CentrisPasserelle\Config\ColumnMap;
use Yeevy\CentrisPasserelle\Parser\ListingsParser;

$columns = ColumnMap::listings()->with([
    'status_code' => 120,   // position vérifiée dans votre documentation
]);

$parser = new ListingsParser($columns);

Un logger PSR-3 peut être injecté ; les lignes sans numéro MLS sont journalisées puis ignorées au lieu d'interrompre l'instantané :

$parser = new ListingsParser(logger: $monLogger);

Si Centris introduit une nouvelle disposition de colonnes, elle sera publiée comme profil nommé plutôt qu'en écrasant la carte par défaut : ColumnMap::listings('2027') chargera config/listings-2027.php, et les profils existants continueront de fonctionner.

Détection de dérive

Un changement de structure du flux ne provoque aucune erreur par lui-même — il se manifeste par des données décalées importées silencieusement. Validez l'instantané avant l'import :

use Yeevy\CentrisPasserelle\Validation\SnapshotValidator;

$validator = new SnapshotValidator($columns);

// Échantillonne les lignes et vérifie les invariants (numéro MLS numérique,
// format des dates, coordonnées dans les bornes du Québec…).
// Lève ColumnMapMismatch si la structure ne correspond plus à la carte,
// ou si l'instantané est vide (ce qui dépublierait toutes les inscriptions).
$validator->validateFile('/chemin/vers/INSCRIPTIONS.TXT');

Les vérifications sont injectables — ajoutez des invariants propres à votre entente ou assouplissez ceux par défaut :

new SnapshotValidator($columns, checks: [
    ...SnapshotValidator::defaultChecks(),
    fn (array $row, ColumnMap $columns): ?string => /* votre invariant */ null,
]);

Cycle de vie des inscriptions

Signal Interprétation
EV (en vigueur) Publier
VE (vendue) Marquer vendue
Absente de l'instantané Dépublier (étape de réconciliation)

Synchronisation

Le moteur applique un instantané complet à votre stockage : validation de dérive d'abord (rien n'est écrit si elle échoue), upsert avec saut des lignes inchangées (dirty hash), puis réconciliation des retraits. Le paquet ne touche jamais à une base de données — implémentez ListingRepository contre votre propre schéma (Eloquent, PDO, WordPress…) :

use Yeevy\CentrisPasserelle\Sync\ListingsSynchronizer;

$synchronizer = new ListingsSynchronizer(
    repository: $monRepository,   // implémente Contracts\ListingRepository
    events: $dispatcherPsr14,     // optionnel — ListingCreated / ListingUpdated / ListingRemoved
);

$result = $synchronizer->sync('/chemin/vers/instantane'); // dossier ou fichier
// $result->created, $result->updated, $result->skipped, $result->removed

Pour récupérer l'instantané depuis le compte FTP Passerelle, utilisez FlysystemFeedSource (installez league/flysystem et league/flysystem-ftp ou -sftp-v3) :

use Yeevy\CentrisPasserelle\Feed\FlysystemFeedSource;

$source = new FlysystemFeedSource($filesystem, localDirectory: '/tmp/centris');

$result = $synchronizer->sync($source);

Si votre entente livre l'instantané en archive ZIP, décorez la source — l'extraction se fait sur place (requiert ext-zip) :

use Yeevy\CentrisPasserelle\Feed\ZipExtractingSource;

$result = $synchronizer->sync(new ZipExtractingSource($source));

Téléchargement des photos

PhotoDownloader télécharge les photos via n'importe quel client PSR-18 (p. ex. Guzzle) dans des fichiers adressés par contenu ({sha256}.jpg) — les octets identiques ne sont stockés qu'une seule fois :

use Yeevy\CentrisPasserelle\Photo\PhotoDownloader;

$downloader = new PhotoDownloader($clientPsr18, $requestFactory, '/chemin/vers/photos');

foreach ($downloader->downloadAll($photosParser->parseFile('/chemin/vers/PHOTOS.TXT')) as $photo) {
    $photo->path;             // /chemin/vers/photos/{sha256}.jpg
    $photo->wasDeduplicated;  // true si les octets existaient déjà
}

download() lève PhotoDownloadFailed ; downloadAll() journalise et ignore les échecs pour qu'une URL brisée n'interrompe jamais le lot. La conversion WebP reste une préoccupation de l'application consommatrice.

Feuille de route

  • Enveloppe Laravel : yeevy/laravel-centris — commande centris:sync, événements Laravel, jobs de photos en file d'attente à venir

Gestion des versions

Le paquet suit SemVer via les étiquettes git.

  • 0.x : les positions de colonnes sont observées par la communauté et l'API se stabilise — des ruptures peuvent survenir dans les versions mineures.
  • À partir de 1.0 : correctifs = patch ; nouveaux champs et analyseurs = mineure ; changement d'API = majeure.
  • Cartes de colonnes : corriger une position par défaut est publié au minimum en version mineure avec une entrée de changelog explicite — le code compile mais les données changent. Une nouvelle disposition Centris devient un nouveau profil nommé, jamais un écrasement du profil existant.

Tests

composer test      # Pest
composer analyse   # PHPStan niveau 8
composer format    # Pint

Important : ne commettez jamais de données réelles du flux. Les tests utilisent exclusivement des fixtures synthétiques.

Licence

MIT — © Digital Unity Inc. (Yeevy)

English

Unofficial open-source PHP client for the Centris® Passerelle FTP feed (Quebec MLS listing data distributed to authorized brokers). Parses, syncs, and reconciles listing data.

Pure PHP core with no framework dependency: consumable from a WordPress plugin, Laravel, Symfony, or a bare cron script.

How the Passerelle feed works

  • No public API. The broker signs a diffusion agreement with Centris/QFREB and receives FTP credentials scoped to their own listings.
  • Centris drops a full snapshot once or twice daily (no deltas): removals are detected by diffing — a listing present in your database but absent from the new file is sold, expired, or withdrawn.
  • Delivered files: INSCRIPTIONS.TXT (listings master), REMARQUES.TXT (FR/EN descriptions), PHOTOS.TXT, ADDENDA.TXT, plus reference files (brokers, agencies, features, municipalities).
  • Format: comma-delimited CSV, quoted fields, Windows-1252 encoding, no header row, positional columns (~150), one listing per CRLF line.

Requirements

  • PHP 8.2+ with the mbstring extension
  • A valid Passerelle diffusion agreement (this package ships no data)

Installation

composer require yeevy/centris-passerelle

Usage

use Yeevy\CentrisPasserelle\Parser\ListingsParser;
use Yeevy\CentrisPasserelle\Enums\ListingStatus;

$parser = new ListingsParser();

foreach ($parser->parseFile('/path/to/INSCRIPTIONS.TXT') as $listing) {
    $listing->mlsNumber;      // "9999999" — upsert key
    $listing->salePrice;      // 975000 (null for rentals)
    $listing->status;         // ListingStatus::Active | ListingStatus::Sold | null
    $listing->descriptionFr;  // contains HTML <br/>
    $listing->descriptionEn;
    $listing->latitude;
    $listing->longitude;
    $listing->dirtyHash;      // sha256 of the raw row — skip unchanged rows on upsert
    $listing->row;            // full raw row for unmapped columns
}

Parsing is lazy (generator-based), so large snapshots don't exhaust memory. Windows-1252 → UTF-8 conversion is applied automatically.

Every file in the drop has its own parser. Detail files join to listings by MLS number; reference files by their own codes:

File Parser Content
REMARQUES.TXT RemarksParser FR/EN descriptions
PHOTOS.TXT PhotosParser Ordered photos, media.ashx URLs
ADDENDA.TXT AddendaParser Chunked addenda to reassemble
CARACTERISTIQUES.TXT FeaturesParser Coded features
DEPENSES.TXT ExpensesParser Taxes and expenses
RENOVATIONS.TXT RenovationsParser Declared renovations
LIENS_ADDITIONNELS.TXT AdditionalLinksParser Virtual tours, videos
VISITES_LIBRES.TXT OpenHousesParser Scheduled open houses
UNITES_DETAILLEES.TXT UnitsParser Units (main, rental, intergenerational)
PIECES_UNITES.TXT RoomsParser Rooms per unit (dimensions, flooring)
MEMBRES.TXT BrokersParser Brokers (key: broker code)
FIRMES.TXT FirmsParser Firms (key: firm code)
BUREAUX.TXT OfficesParser Offices (key: office code)
use Yeevy\CentrisPasserelle\Parser\PhotosParser;

foreach ((new PhotosParser())->parseFile('/path/to/PHOTOS.TXT') as $photo) {
    $photo->mlsNumber;      // join key
    $photo->sequence;       // display order
    $photo->categoryCode;   // FACA = façade, CUI = kitchen, SDB = bathroom…
    $photo->url;            // https://mediaserver.centris.ca/media.ashx?id=…
}

Column positions

The positions shipped with the package are community-observed and may vary by agreement version. Verify them against the Passerelle PDF documentation that came with your agreement, then override as needed:

use Yeevy\CentrisPasserelle\Config\ColumnMap;
use Yeevy\CentrisPasserelle\Parser\ListingsParser;

$columns = ColumnMap::listings()->with([
    'status_code' => 120,   // position verified in your documentation
]);

$parser = new ListingsParser($columns);

A PSR-3 logger can be injected; rows without an MLS number are logged and skipped instead of aborting the snapshot:

$parser = new ListingsParser(logger: $myLogger);

If Centris introduces a new column layout, it will ship as a named profile rather than overwriting the default map: ColumnMap::listings('2027') loads config/listings-2027.php, and existing profiles keep working.

Drift detection

A feed structure change raises no error by itself — it shows up as shifted data imported silently. Validate the snapshot before importing:

use Yeevy\CentrisPasserelle\Validation\SnapshotValidator;

$validator = new SnapshotValidator($columns);

// Samples rows and checks invariants (numeric MLS number, date format,
// coordinates within Quebec bounds…). Throws ColumnMapMismatch when the
// structure no longer lines up with the map, or when the snapshot is
// empty (which would unpublish every listing).
$validator->validateFile('/path/to/INSCRIPTIONS.TXT');

Checks are injectable — add per-agreement invariants or relax the defaults:

new SnapshotValidator($columns, checks: [
    ...SnapshotValidator::defaultChecks(),
    fn (array $row, ColumnMap $columns): ?string => /* your invariant */ null,
]);

Listing lifecycle

Signal Interpretation
EV (en vigueur) Publish
VE (vendue) Mark sold
Absent from snapshot Unpublish (reconciliation step)

Synchronization

The engine applies a full snapshot to your storage: drift validation first (nothing is written if it fails), upserts with unchanged-row skipping (dirty hash), then removal reconciliation. The package never touches a database — implement ListingRepository against your own schema (Eloquent, PDO, WordPress…):

use Yeevy\CentrisPasserelle\Sync\ListingsSynchronizer;

$synchronizer = new ListingsSynchronizer(
    repository: $myRepository,    // implements Contracts\ListingRepository
    events: $psr14Dispatcher,     // optional — ListingCreated / ListingUpdated / ListingRemoved
);

$result = $synchronizer->sync('/path/to/snapshot'); // directory or file
// $result->created, $result->updated, $result->skipped, $result->removed

To fetch the snapshot from the Passerelle FTP account, use FlysystemFeedSource (install league/flysystem plus league/flysystem-ftp or -sftp-v3):

use Yeevy\CentrisPasserelle\Feed\FlysystemFeedSource;

$source = new FlysystemFeedSource($filesystem, localDirectory: '/tmp/centris');

$result = $synchronizer->sync($source);

If your agreement delivers the snapshot as a ZIP archive, decorate the source — extraction happens in place (requires ext-zip):

use Yeevy\CentrisPasserelle\Feed\ZipExtractingSource;

$result = $synchronizer->sync(new ZipExtractingSource($source));

Photo downloads

PhotoDownloader fetches photos through any PSR-18 client (e.g. Guzzle) into content-addressed files ({sha256}.jpg) — identical bytes are stored exactly once:

use Yeevy\CentrisPasserelle\Photo\PhotoDownloader;

$downloader = new PhotoDownloader($psr18Client, $requestFactory, '/path/to/photos');

foreach ($downloader->downloadAll($photosParser->parseFile('/path/to/PHOTOS.TXT')) as $photo) {
    $photo->path;             // /path/to/photos/{sha256}.jpg
    $photo->wasDeduplicated;  // true when the bytes already existed
}

download() throws PhotoDownloadFailed; downloadAll() logs and skips failures so one broken URL never aborts the batch. WebP conversion stays a consumer-application concern.

Roadmap

  • Laravel wrapper: yeevy/laravel-centriscentris:sync command, Laravel events, queued photo jobs upcoming

Versioning

The package follows SemVer via git tags.

  • 0.x: column positions are community-observed and the API is still settling — breaking changes may land in minor versions.
  • From 1.0 on: fixes = patch; new fields and parsers = minor; API changes = major.
  • Column maps: correcting a shipped default position is released as at least a minor version with an explicit changelog entry — code still compiles, but data shifts. A new Centris layout becomes a new named profile, never an overwrite of an existing one.

Testing

composer test      # Pest
composer analyse   # PHPStan level 8
composer format    # Pint

Important: never commit real feed data. Tests use synthetic fixtures only.

License

MIT — © Digital Unity Inc. (Yeevy)