hokoo / wpconnections
A library for many-to-many relationships in WordPress
Requires
- php: ^8.1
- hokoo/wp-hooks-dispatcher: ^1.0.1
- psr/log: >=1.1
- ramsey/collection: ^1.3 || ^2.1.1
Requires (Dev)
- composer/installers: ^2.3
- dealerdirect/phpcodesniffer-composer-installer: ^1.0
- johnpbloch/wordpress: ^6.7
- johnpbloch/wordpress-core-installer: ^2.0
- phpcompatibility/phpcompatibility-wp: ^2.1
- phpunit/phpunit: 9.6.x-dev
- squizlabs/php_codesniffer: ^3.13.6
- symfony/var-dumper: 5.4.x-dev
- wp-coding-standards/wpcs: ^3.4.1
- yoast/phpunit-polyfills: ^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- dev-batch18-repair-runner-closeout
- dev-batch18-deleted-post-repair-runner
- dev-api20-decision-activation
- dev-batch17-repair-ledger-closeout
- dev-batch17-deleted-post-repair-ledger
- dev-batch16-delete-repair-closeout
- dev-batch16-delete-repair-design
- dev-batch16-rest-closeout
- dev-batch16-rest-contract
- dev-batch15-closeout
- dev-batch15-delete-conformance
- dev-batch14-closeout
- dev-batch14-atomic-storage
- dev-batch13-schema-lifecycle-closeout
- dev-batch13-schema-lifecycle
- dev-batch12-delete-success-closeout
- dev-batch12-delete-success-contract
- dev-codex/batch11-closeout
- dev-codex/connection-update-contract
- dev-codex/batch10-closeout
- dev-codex/rest-hook-context-routing
- dev-codex/log-hook-origin-routing
- dev-codex/owned-hook-inventory
- dev-codex/hook-manager-selection
- dev-codex/hook-transition-api
- dev-codex/core-06-client-isolation
- dev-codex/core-04-entity-validation
- dev-codex/core-07-query-meta
- dev-codex/batch-6-activation
- dev-codex/batch-5-closeout
- dev-codex/batch-5-activation
- dev-codex/core-00-entity-contract
- dev-codex/spi-01-storage-contract
- dev-codex/core-02-cardinality
- dev-codex/rest-00b-partial-update-contract
- dev-codex/test-01-integration-fixtures
- dev-codex/library-hardening-plan
- dev-codex/dockerfile-ci-transition
- dev-codex/ramsey-collection-compat
- dev-fix-dbdelta
- dev-codex/add-dockerfile-for-phpunit-tests
- dev-fix-tests
- dev-issue-35-permisstions-policy
- dev-issue-32-fix-wp-units-github
- dev-issue-37-local-dev
- dev-issue-33-cardinality
- dev-duplicatable-put-to-first
- dev-connection-save
- dev-ga-refactored
- dev-local-wp-tests
- dev-exception-codes
- dev-readme
- dev-wp-unit-tests
- dev-github-autotests
- dev-fix-order
- dev-feature-add-wpcs
- dev-ConnectionTests
- dev-docs
- dev-dev
- dev-restapi
This package is auto-updated.
Last update: 2026-09-21 19:32:30 UTC
README
wpConnections allows to link posts in WordPress by graph-like connections. The library provides such connection properties as:
- direction (from, to)
- from post id
- to post id
- order
- meta data.
Connections belong to a Relation and never exist out. The relation has properties:
- cardinality (1-1, 1-m, m-1, m-m)
- from post type
- to post type
- direction type (from, to, both)
- duplicatable (whether may have same connections)
- closurable (whether may have the same post on from and to).
EXAMPLE. There are four CPT:
magazine,issue,articleandauthor. Magazine posts may have connections with some Issues (one-to-many type) so that the Issues constitute the Magazine.
The Issues in turn have connections with Articles (one-to-many as well). But an Author might have been linked with many Articles, and an Article might have many connections with Authors (many-to-many).
Why wpConnection?
It can be used as multiple installed library being parts of different plugins in a WordPress installation. All you need is creating a client instance for your application. Every client has its own tables and REST API identity and does not influence other clients.
Ok, what should I do to start using?
Full documentation is available on Wiki project pages.
Add the package
composer require hokoo/wpconnections
So, you have to create client instance...
use iTRON\wpConnections\Client; $wpc_client = new Client( 'my-app-wpc-client' );
...and relations for your connections.
use iTRON\wpConnections\Query; $qr = new Query\Relation(); $qr->set( 'name', 'post-to-page' ); $qr->set( 'from', 'post' ); $qr->set( 'to', 'page' ); $qr->set( 'cardinality', 'm-m' ); $wpc_client->registerRelation( $qr );
Ok, now you can create connections inside the relation.
$qc = new Query\Connection(); $qc->set( 'from', $post_id_from ); $qc->set( 'to', $post_id_to ); $wpc_client->getRelation( 'post-to-page' )->createConnection( $qc );
Atomic compound mutations
The default WPStorage adapter commits connection-plus-metadata changes as one
unit. This applies automatically to create with metadata, aggregate
Connection::update(), relation deletes, direct default-storage delete
cascades and the legacy deleted_post callback. A database failure or callback
Throwable rolls the whole unit back; success-named mutation hooks run only
after commit. A hook exception still propagates synchronously, but storage is
already durable at that point.
Applications can group multiple operations for one Client in a library-owned root scope:
$result = $wpc_client->runAtomically( function () use ( $wpc_client, $first_query, $second_query ) { $relation = $wpc_client->getRelation( 'post-to-page' ); $first = $relation->createConnection( $first_query ); $relation->createConnection( $second_query ); return $first; } );
If the application already owns the database transaction, it must declare a nested scope and coordinate success notifications with the real outer outcome:
use iTRON\wpConnections\TransactionContext; use iTRON\wpConnections\TransactionSynchronizer; global $wpdb; $synchronizer = new TransactionSynchronizer(); $wpdb->query( 'START TRANSACTION' ); try { $result = $wpc_client->runAtomically( $operation, TransactionContext::nested( $synchronizer ) ); } catch ( \Throwable $failure ) { $wpdb->query( 'ROLLBACK' ); $synchronizer->rolledBack(); throw $failure; } if ( false === $wpdb->query( 'COMMIT' ) ) { $wpdb->query( 'ROLLBACK' ); $synchronizer->rolledBack(); throw new \RuntimeException( 'Outer transaction commit failed.' ); } // May propagate a success-hook Throwable; the transaction is already durable. $synchronizer->committed();
Create a fresh synchronizer for each outer transaction and notify it exactly once. A nested library scope uses a collision-safe savepoint and never commits the outer transaction. One scope cannot span multiple wpConnections Clients; cross-client re-entry is rejected before the second Client writes.
If WPStorage cannot confirm a required ROLLBACK or ROLLBACK TO SAVEPOINT,
the shared $wpdb session is treated as unsafe. Every default-storage Client
using that session then rejects a new atomic scope before schema checks or DML,
rather than risk that a later START TRANSACTION implicitly commits uncertain
work. A successful rollback by a library-owned ancestor clears this state. If
the top-level or consumer-owned rollback itself was not confirmed, there is no
public in-request reset: finish the request and use a fresh $wpdb session.
Issuing a manual SQL rollback does not clear the library's fail-closed marker.
Custom adapters keep the existing Abstracts\Storage surface. They must also
implement AtomicStorageInterface to accept compound domain mutations;
otherwise those mutations throw StorageCapabilityUnavailable before the
first storage write. Adapters that share one backend session between Clients
must also coordinate rollback uncertainty for that shared session rather than
tracking it per adapter instance. Scalar relation updates and creates without
metadata do not require the optional capability. See the
storage SPI contract for exact result, failure
and hook semantics.
Endpoint validation compatibility
High-level create and update operations require both endpoint IDs to resolve to
the exact physical from/to WordPress post types declared by the relation.
Custom non-post types require a client-scoped EntityResolverInterface before
the client's first connection mutation. Direct storage calls remain a legacy
SPI and do not receive these domain guarantees.
Before upgrading an installation with existing data, run a read-only,
client-by-client inventory for missing IDs, wrong post types and relation types
without a registered resolver. Legacy-invalid rows remain readable and can be
deleted, including through the REST cleanup delegates and deleted_post
cascade, but cannot be updated through the domain API until repaired. The
library performs no automatic scan, repair or destructive migration. See the
entity-validation contract and preflight guidance.
Client identity and table isolation
Client names must be strings. They are normalized once with WordPress
sanitize_title() and the result must be non-empty lower-case ASCII containing
only letters, digits, _ or -. The default WPStorage adapter preserves the
legacy table mapping by replacing hyphens with underscores; for example,
my-app-wpc-client uses unprefixed table names
post_connections_my_app_wpc_client and
post_connections_meta_my_app_wpc_client.
Both complete names, including the current site prefix, must fit the database's
64-character identifier limit. A versioned, non-autoloaded site-local WordPress
option claims each fresh table postfix atomically, so distinct logical names
such as my-client and my_client cannot silently share one pair. A default
storage object is bound to the WordPress site prefix used at construction and
must be recreated after switch_to_blog(); custom non-table Storage adapters
receive only the logical-name rules. Direct access through a stale default
storage object throws the documented prefix error. Its globally registered
deleted_post callback instead becomes a no-op before storage hooks or SQL, so
it cannot prevent the fresh current-site client from running its own cascade.
The 1.x callback remains the concrete storage method at priority 10, preserving
existing remove_action() usage. Cleanup is enabled automatically at Client
construction and can now be controlled without depending on callback identity:
$client->disablePostDeletionCleanup(); $client->enablePostDeletionCleanup();
Both commands are idempotent. Direct callback removal remains compatible in 1.x, but consumers should migrate to these semantic methods before 2.0: the context-aware subscription manager planned for that major version will own a different WordPress callback identity. See the hook lifecycle transition contract.
Existing complete tables without a matching ownership record, unowned partial pairs, and malformed or conflicting records are rejected without automatic repair, rename, or delete. A partial pair with the matching ownership record is different: the schema lifecycle may recreate only its missing table once before the first create DML, then proceeds only after both tables pass structural and InnoDB verification. Operator-facing inventory and explicit attestation remain follow-up work in REL-03; no unclaimed installation is adopted implicitly. See the client naming and migration contract.
Automatic debug logging and storage event origins
When WP_DEBUG is enabled, the library registers one process-global observer
for its three automatically logged storage events. The observer routes each
event to the logger owned by the originating Client; creating more clients
does not add more logging callbacks or broadcast an operation to other client
loggers.
Custom Storage implementations that emit these public actions must include
the origin in the documented position to receive automatic logging:
| Action | Arguments |
|---|---|
wpConnections/storage/findConnections/dbQuery |
SQL/query payload, raw result, trailing Client origin |
wpConnections/storage/removeConnectionMeta/after |
Client origin, object ID, meta selector, query payload, affected rows |
wpConnections/storage/deletedSpecificConnections |
Client origin, normalized connection IDs, affected rows |
The query action's third argument is additive: WordPress listeners registered
with an accepted-argument count of two continue to receive the original two
values. The origin is routing metadata and is not added to the PSR logger's
legacy context. If an event omits a valid origin, consumer callbacks still run,
but library-owned automatic logging safely skips that event. The default
Logger::log() continues to emit the logger compatibility action.
The observer remains a priority-10 callback inside each public action. Default
storage success-named mutation actions are now queued by DB-05 until the owning
root commit or the caller's explicit outer-commit confirmation. Attempt and
before actions retain their pre-mutation meaning.
Multisite REST lifecycle and custom delegates
A Client belongs to the WordPress site context in which it is constructed.
Consumers that use switch_to_blog() must construct and initialize a separate
client for every switched site; the library does not create clients during a
blog switch. The REST transport keeps one live owner for each blog ID, database
prefix and canonical client name, and rejects a second live owner with
ClientRegisterFail. A client created after rest_api_init is bound to the
existing REST server immediately.
Library-owned REST routes use context-neutral callbacks. Before permissions or
a route handler can reach client code, the current blog ID and database prefix
must select that site's live client. A valid request to a route that remains in
a reused server after its owner is unavailable receives WordPress's native
rest_no_route response with HTTP 404. WordPress validates route arguments
first, so a malformed request can instead receive its native validation 400;
neither path invokes a stale client's permission, handler or storage code.
The wpConnections/factory/getRestApi/class filter remains available. A custom
ClientRestApi subclass may customize $namespace, $base, permissions and
the built-in handlers, but an overridden init() must call parent::init().
The library now owns registration of its four built-in route patterns, so an
override of registerRestRoutes() is not invoked automatically. Extra hooks or
routes registered by a custom subclass remain the implementer's lifecycle and
multisite responsibility. These are intentional 2.0 compatibility boundaries;
review custom REST subclasses before upgrading.
Deprecations
Connection::load() is a deprecated legacy no-op and will be removed in
2.0.0. Use Relation::findConnections() to query existing connections. It
remains callable without a runtime notice during the current 1.x-compatible
line. See the deprecation and migration guide.
Since you have initialized new client, its REST API endpoints are available.
http://cf7tgdev.loc/wp-json/wp-connections/v1/client/my-app-wpc-client/
Local Development
Prerequisites
- Windows 10 or later (WSL2), or Linux, or MacOS
- Docker Desktop, Docker Compose v2
- Make
Installation
- Clone this repo to the Ubuntu disk space. Location path should look like
\\wsl$\Ubuntu-20.04\home\username\path\to\the\repo. - Make sure you have
makeinstalled in your system. If not, runsudo apt install make. - Make sure you have installed Docker Desktop with configured WSL2 support if you are using Windows.
- Add
127.0.0.1 wpconnections.localto the hosts file (on the host machine). - Run the following command in the root directory to install the project:
bash ./local-dev/init.sh && make docker.up && make dev.install
Running the test suites
The project ships with a dedicated Dockerfile.phpunit image that bundles Composer, the WordPress test library and an embedded MariaDB server so the entire PHPUnit stack runs inside a single container locally and in CI. After the installation step you can run all tests from the project root with:
make tests.run
Behind the scenes this calls the phpunit service defined in local-dev/docker-compose.yml and aggregates the same entrypoint checks that GitHub Actions runs separately. The service no longer depends on any other containers: the entrypoint installs Composer dependencies when needed, spins up MariaDB only for WP integration tests, and configures the WordPress test library on demand.
make tests.run is the fast development loop: it reuses the existing test image,
while an idempotent composer install synchronizes the bind-mounted vendor/
directory with composer.lock before PHPUnit starts. Repeated runs with an
up-to-date lock file do not download the dependencies again. Before Composer
runs, the local entrypoint verifies that the image matches the current
Dockerfile, entrypoint, PHP input and WordPress input; a stale image fails with
the exact rebuild commands to use.
You can also run individual checks from the project root:
make tests.phpunit make tests.integration make tests.coverage make lint.phpcs
See docs/ci-runbook.md for the canonical CI matrix,
local parity commands, coverage policy and failure-triage procedure.
make tests.coverage builds a deterministic PHP 8.1.34 / WordPress 6.7.7
image, runs the unit and WordPress integration suites in one instrumented
process, and checks the resulting statement coverage against the repository
baseline. The human-readable and machine-readable reports are written to
build/coverage/. The baseline stores the exact covered/total ratio rather
than a rounded percentage; update it only when a reviewed source or test change
intentionally changes the accepted baseline.
Compatibility test matrix
Blocking CI uses exact version pins. Unit tests run the full Cartesian matrix of
PHP 8.1.34, 8.2.33, 8.3.33, 8.4.25 and 8.5.10 against Ramsey
Collection 1.3.0 and 2.1.1 (ten jobs). WordPress integration tests use this
pairwise matrix:
| PHP | WordPress | Ramsey Collection |
|---|---|---|
| 8.1.34 | 6.7.7 | 1.3.0 |
| 8.2.33 | 7.1.0 | 1.3.0 |
| 8.3.33 | 7.1.0 | 2.1.1 |
| 8.4.25 | 6.7.7 | 2.1.1 |
| 8.5.10 | 7.1.0 | 2.1.1 |
Database behavior has its own blocking matrix so it is not inferred from the MariaDB package bundled in the PHP test image:
| Database | Exact blocking image |
|---|---|
| MySQL 8.0.46 | mysql:8.0.46@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b |
| MariaDB 10.11.16 | mariadb:10.11.16@sha256:4045aba619003d93b5dc834e89e6815ba078d2cb3ff0a26f316ab5d7eab35093 |
Both database lanes run the full WordPress integration suite on the fixed PHP 8.1.34 / WordPress 6.7.7 / Ramsey Collection 1.3.0 floor. New wpConnections tables are explicitly InnoDB; an existing MyISAM or mixed-engine client schema must be migrated by an administrator and is never converted during a request.
WordPress 6.7.7 is the pinned compatibility-floor lane, not a claim that this older branch is still maintained upstream. Production installations should follow the current WordPress security guidance. The exact stable pin is updated deliberately when the supported matrix changes.
The scheduled WP Trunk Canary workflow runs WordPress trunk with PHP 8.5.10
and Ramsey Collection 2.1.1. It is not a pull-request or required check: a
failure is an upstream compatibility signal to triage, not a reason to make the
pinned blocking jobs non-reproducible. It can also be started manually with
workflow_dispatch.
The johnpbloch/wordpress package in require-dev is retained as a development
fixture. Docker integration tests load both core and the test library from the
same pinned wordpress-develop archive, so that Composer fixture neither selects
the Docker runtime nor defines this compatibility matrix.
The supported local interface uses Compose v2 (docker compose) consistently.
make tests.integration is the canonical WordPress integration-test target;
the former make tests.wpunit alias has been removed.
Rebuild the test image after changing Dockerfile.phpunit or its build inputs:
make tests.build
For a clean verification, rebuild the image without Docker layer cache and then run both test suites:
make tests.clean
The underlying no-cache build is also available separately as
make tests.rebuild.
make tests.init is only needed for direct, non-Docker WordPress PHPUnit runs that rely on a local wordpress-develop checkout. The default local and CI paths use Dockerfile.phpunit.
The same Dockerfile is used by GitHub Actions workflows for unit tests and PHP code style checks. WordPress defaults to the exact stable pin 7.1.0; another release must be an exact x.y.z tag passed with --build-arg WP_VERSION=6.7.7. The only symbolic input is trunk; ambiguous latest and the stale GitHub master branch are rejected.