Call Any Backbone-Dispatcher Library From Python

GitHub last commit CI Workflow GitHub code size in bytes GitHub Issues PyPI version PyPI downloads

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.

PyPI has an unrelated package also named `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.

On this page

#python
Last updated on 15/08/2026 by Anonymous