A production-minded WordPress plugin that synchronizes real-estate listings from an external REST API with idempotent updates, WP-Cron, structured logs, Docker, tests, and CI.
Property Sync API imports a paginated property feed into a public WordPress
property custom post type. It validates upstream data, maps taxonomies and
metadata, detects real content changes, and gives administrators one place to
configure, run, and audit synchronization.
The project favors WordPress-native APIs and explicit component boundaries over framework-heavy infrastructure. It remains small enough to understand quickly while covering the failure, security, and concurrency concerns expected from a real integration.
Real-estate companies often keep property data in external CRMs or management systems while using WordPress as their public website. Keeping both systems in sync manually is slow, error-prone, and creates duplicate or stale listings.
Property Sync API demonstrates how an external feed can be imported safely while avoiding duplicate posts and unnecessary database writes.
- Public
propertyCPT with a/properties/archive and REST-enabled property type, city, and status taxonomies. - Authenticated, paginated REST client with response validation, a 15-second timeout, redirect limit, and page limit.
- Canonical normalization and SHA-256 change detection for idempotent create, update, and skip behavior.
- Explicit duplicate external-ID detection and isolated validation/persistence failures per listing.
- Administrator dashboard with secure settings, masked token, manual sync, last-run counters, and recent activity.
- Native WP-Cron schedules and a 15-minute concurrency lock with stale-lock recovery and ownership-safe release.
- Structured logging with secret redaction and a 30-day / 5,000-row retention policy.
- Reproducible Docker environment, PHPUnit, WordPress smoke tests, PHPCS, PHPCompatibility, and GitHub Actions.
Admin POST / WP-Cron
|
v
SyncRunner
|-- SyncLock
|-- PropertyApiClient
|-- PropertyNormalizer
|-- PropertyHasher
|-- PropertyRepository
`-- SyncLogger
Plugin is the composition root and wires concrete dependencies explicitly.
Each component owns one integration concern; there is no service container,
queue, or repository abstraction without a concrete need. See
docs/architecture.md for the complete design and data
model.
- A manual action or WP-Cron invokes the same
SyncRunneruse case. - The runner acquires an atomic lock and creates a run ID.
- The API client reads and validates every page.
- Each listing is normalized and fingerprinted from its canonical content.
- The repository finds the post by exact external ID, then creates, updates, or skips it according to the SHA-256 hash.
- Counters and structured events are stored before the lock is released in
finally.
An invalid listing or known persistence rejection increments the error counter and does not block later valid items. API-level failures and unexpected programming errors abort the run; unexpected errors are logged without a stack trace or sensitive payload and are rethrown instead of being silently hidden.
The external ID selects the WordPress post. A stable hash of recursively sorted
canonical content determines whether a write is necessary; upstream
updated_at remains audit data rather than the only change signal.
An atomic, non-autoloaded WordPress option prevents overlapping runs. Its 15-minute TTL allows recovery after an interrupted process, and only the owner token can release it.
Malformed payloads and known WordPress persistence failures are isolated per
listing. TypeError, Error, and other unexpected failures propagate after a
safe diagnostic event is recorded, making programming defects visible.
Native hourly, twicedaily, and daily WP-Cron intervals keep deployment
simple. Because WP-Cron depends on site traffic, time-critical installations
should trigger wp-cron.php from a system scheduler.
Missing upstream listings are not deleted, and image URLs are stored without sideloading. Both choices avoid destructive or storage-heavy behavior until a real product requirement defines the policy.
The stored API token is never rendered back to the browser.
This real demo run processed five listings: two created, one updated, two unchanged, and no errors.
Events expose operational outcomes and external IDs without storing tokens, authorization headers, or raw payloads.
- Docker and Docker Compose
- Git
PHP and Composer are provided through Docker, so neither is required on the host machine.
-
Create the local environment file:
cp .env.example .env
-
Start the services:
docker compose up -d
-
Install WordPress, configure rewrites, and activate the plugin:
docker compose run --rm wpcli wp core install --url=http://localhost:8080 --title="Property Sync" --admin_user=admin --admin_password=admin --admin_email=admin@example.com --skip-email docker compose run --rm wpcli wp rewrite structure '/%postname%/' --hard docker compose run --rm wpcli wp plugin activate property-sync
Open http://localhost:8080/wp-admin/, sign in with admin / admin, and
choose Property Sync. These credentials are for local development only.
Use the bundled deterministic feed with:
API URL: http://mock-api:3000/properties
API token: demo-token
Sync interval: Disabled (or a native WP-Cron interval)
The API is also available from the host at http://localhost:3001/properties. Its payload contract and deterministic failure scenarios are documented in docs/api-contract.md.
For a deployed environment, define the token outside the database:
define( 'PROPERTY_SYNC_API_TOKEN', 'replace-with-a-secret' );The constant takes precedence and makes the admin token field read-only.
- Settings and manual runs require
manage_options; the manual POST action also verifies a nonce and redirects after processing. - Production API endpoints require HTTPS. HTTP is limited to known local development hosts.
- Saved tokens use a non-autoloaded option, stay masked in the UI, and are excluded from logs together with credential-shaped context.
- Input is validated or sanitized at its boundary, output is escaped when rendered, and HTTP/page limits constrain external work.
- Internal property metadata is not exposed automatically through REST.
docker compose ps
docker compose logs -f wordpress
docker compose run --rm composer-install validate --strict
docker compose run --rm composer-install check
docker compose run --rm composer-install lint:fix
docker compose run --rm wpcli wp plugin status property-syncDo not run docker compose down -v unless you intentionally want to remove the
local database and uploads. Plain docker compose down keeps them.
The unit suite covers pure normalization and hashing behavior. Integration smoke scripts exercise the WordPress content model, API client, persistence, create/update/skip behavior, expected failure isolation, unexpected error propagation, logs, manual action, cron, and lock release.
docker compose run --rm composer-install check
docker compose run --rm wpcli wp eval-file wp-content/plugins/property-sync/tests/Smoke/content-model.php
docker compose run --rm wpcli wp eval-file wp-content/plugins/property-sync/tests/Smoke/admin-settings.php
docker compose run --rm wpcli wp eval-file wp-content/plugins/property-sync/tests/Smoke/api-client.php
docker compose run --rm wpcli wp eval-file wp-content/plugins/property-sync/tests/Smoke/api-client-mock.php
docker compose run --rm wpcli wp eval-file wp-content/plugins/property-sync/tests/Smoke/property-normalization.php
docker compose run --rm wpcli wp eval-file wp-content/plugins/property-sync/tests/Smoke/property-sync.php
docker compose run --rm wpcli wp eval-file wp-content/plugins/property-sync/tests/Smoke/sync-logging.php
docker compose run --rm wpcli wp eval-file wp-content/plugins/property-sync/tests/Smoke/manual-sync-dashboard.php
docker compose run --rm wpcli wp eval-file wp-content/plugins/property-sync/tests/Smoke/cron-and-lock.phpGitHub Actions runs Composer install, PHPCS, and PHPUnit for pull requests and
pushes to develop and main.
feature/* or fix/*
|
v
develop -- CI and integration
|
v
main -- tagged semantic releases
Future maintenance uses focused branches, Conventional Commits, pull requests, and the existing lightweight issue/PR templates. The project history is kept authentic; no retrospective issues or pull requests are manufactured.
Release descriptions follow docs/release-template.md.
The MVP intentionally excludes automatic deletion of missing listings, image sideloading, CSV export, custom cron intervals, WP-CLI commands, incremental cursors, queues, Redis, multisite, GraphQL, webhooks, and bidirectional sync. These are possible follow-ups only when a concrete production requirement justifies their complexity.
This project demonstrates a production-minded WordPress integration, with emphasis on maintainability, safe synchronization, failure handling, testing, and professional development practices rather than UI complexity.
Copyright (c) 2026 Andrey da Hora Pirola.
This project is licensed under GPL-2.0-or-later. You may use, modify, and redistribute it under the GNU General Public License, version 2 or any later version published by the Free Software Foundation.



