Call Any Backbone-Dispatcher Library From Python
Generic Python bridge for any derafu/backbone-dispatcher-based PHP library, via swoole/phpy — without a caller ever having to know phpy exists.
Why
derafu/backbone-dispatcher’s SafeDispatcherInterface already turns any Backbone operation into something that never throws a raw PHP exception and always returns a serializable value — exactly the contract a foreign-language caller needs. swoole/phpy lets Python call into a real, embedded PHP interpreter in the same process. This package is the glue between the two: it boots a real SafeDispatcherInterface and exposes it as dispatch(operation_id, **params), translating PHP exceptions into typed Python exceptions on the way back.
It knows nothing about any specific library. A specific PHP library’s own bridge subclasses GenericDispatcher, pre-wiring how its own SafeDispatcherInterface gets booted — this is exactly what this package’s own test suite does, against a real, minimal PHP fixture (no mocks):
class ExampleDispatcher(GenericDispatcher):
_BOOTSTRAP_CLASS = 'Derafu\\TestsBackboneBridgePython\\Fixture\\Bootstrap'
def __init__(self, autoload_path=None):
super().__init__(self._BOOTSTRAP_CLASS, autoload_path=autoload_path)
Installation
pip install derafu-backbone-bridge
phpy itself is not a normal pip dependency and never will be resolvable from PyPI: it requires a PHP build with --enable-embed, so it must already be present system-wide wherever this package runs — see Docker with Python and Caddy for a ready-made image with an optional PHPY_ENABLED build. Install this package into a virtualenv created with --system-site-packages so it can see the system phpy.
There is a long-abandoned, unrelated package on PyPI called phpy (“call legacy PHP functions from Python”, 2013). It is not swoole/phpy. This package deliberately does not declare phpy as a dependency at all, specifically so pip install never silently resolves that decoy instead of the real, system-provided one.
Usage
from derafu_backbone_bridge import GenericDispatcher
dispatcher = GenericDispatcher(
'Derafu\\TestsBackboneBridgePython\\Fixture\\Bootstrap',
autoload_path='/path/to/backbone-bridge-python/tests/php/vendor/autoload.php',
)
value = dispatcher.dispatch(
'example_package.example_component.example_worker:sum',
a=5, b=7,
)
# value == 12
operation_id uses the same "package.component.worker:operation" format as OperationRequest::fromId() on the PHP side. Every keyword argument becomes a named operation parameter — b falls back to the operation’s own PHP default (10) when omitted, exactly as it would for a direct PHP caller.
Where the PHP autoloader lives
GenericDispatcher resolves the PHP autoloader to phpy.include() from, in order: an explicit autoload_path argument, or the BACKBONE_DISPATCHER_AUTOLOAD environment variable. Exactly one variable is used regardless of which library is being bridged, because in practice a given process only ever hosts one bridge:
export BACKBONE_DISPATCHER_AUTOLOAD=/path/to/backbone-bridge-python/tests/php/vendor/autoload.php
dispatcher = ExampleDispatcher() # No path needed if the env var is set.
Neither of those is phpy-specific knowledge — they’re just “where does my dependency live,” the same kind of configuration any bridge would need regardless of the underlying interop mechanism.
Handling Errors
A failed operation raises BackboneBridgeError or one of its subclasses — never a raw phpy call failure, and never a PHP exception object:
from derafu_backbone_bridge import MissingParameterError
try:
dispatcher.dispatch('example_package.example_component.example_worker:sum')
except MissingParameterError as e:
print(e.php_class) # "Derafu\BackboneDispatcher\Exception\MissingParameterException"
The hierarchy mirrors derafu/backbone and derafu/backbone-dispatcher’s own exceptions one level deep (ServiceNotFoundError/PackageNotFoundError/ComponentNotFoundError/…, ResolverError/InvalidParameterTypeError/…), so callers can catch broadly or narrowly exactly as they would in PHP. Anything unmapped still raises BackboneBridgeError, preserving the original PHP class name in .php_class:
from derafu_backbone_bridge import BackboneBridgeError
try:
dispatcher.dispatch('example_package.example_component.example_worker:fail')
except BackboneBridgeError as e:
print(e.php_class) # "RuntimeException" — a plain, unmapped PHP exception.
A specific library’s own domain exceptions are registered by its own bridge, not by this package:
dispatcher.exceptions.register('App\\Exception\\SomeDomainException', SomeDomainError)
Separately, a failure while booting PHP itself (a missing autoloader, a broken dependency, a bootstrap class or method that doesn’t exist) raises BootstrapError — never a raw phpy error either, but also never confused with an operation failure, since booting happens before any SafeDispatcherInterface exists to produce a ProblemDetail from.
Architecture
GenericDispatcher is the only place in the whole dependency chain that imports phpy — not the tests, not a specific library’s own bridge, not the application consuming it. Building one specific library’s bridge means subclassing GenericDispatcher with its bootstrap_class, and never touching phpy directly:
class GenericDispatcher:
_AUTOLOAD_PATH_ENV = 'BACKBONE_DISPATCHER_AUTOLOAD'
def __init__(
self,
bootstrap_class: str,
bootstrap_method: str = 'boot',
bootstrap_args: tuple = (),
exception_registry: ExceptionRegistry | None = None,
autoload_path: str | None = None,
) -> None: ...
def dispatch(self, operation_id: str, **params) -> Any: ...
ExceptionRegistry (the PHP-class-name → Python-exception mapping) is a separate, independent component — it never touches phpy either, and can be tested and reasoned about with no PHP interpreter involved at all.
Requirements
Python 3.14+. phpy itself requires PHP built with --enable-embed.