py / bridge
"Python-In-PHP" allows you to use any Python packages directly in PHP, as if they were native PHP classes. This PHP-Python bridge comes with built-in package manager, which is integrated directly into Composer
Requires
- php: >=8.2
- composer-plugin-api: ^2.6
- ext-sockets: *
- nette/php-generator: ^4.2
- textalk/websocket: ^1.6
Requires (Dev)
- composer/composer: ^2.8
- pestphp/pest: ^4.0
Suggests
- psr/log: Pass a PSR-3 logger as the 'logger' bridge option to receive the Python worker's output
Provides
None
Conflicts
None
Replaces
None
README
π₯ Fully use artificial intelligence frameworks for AI models inference or training directly in PHP!
You can run AI models with libraries like transformers, torch, vllm, numpy, etc. in your PHP project with PHP syntax.
See syncfly/transformers-torch-php for a quick start with transformers and torch.
System requirements
| Requirement | Version |
|---|---|
| PHP | β₯ 8.2 |
| OS | Linux, macOS, Windows |
| Architecture | x86_64, arm64 |
The library automatically downloads and installs the uv tool and a Python environment on first use. No manual Python installation is required.
Note for Windows users: symlink creation may require Administrator privileges or Developer Mode enabled.
Installation
composer require syncfly/python-in-php
Answer yes when Composer asks to activate the plugin. This will:
- Download
uv(~10 MB) intovendor/bin/ - Create a Python virtual environment in
vendor/bin/python-in-php/ - Generate PHPDoc stubs for installed packages in
py/
βοΈ Please star the repository to show that there is demand for it, so that you can be sure it will continue to be maintained
Quick start
After installation, Python's standard library is available immediately:
<?php use py\json; use py\datetime\datetime; echo json::dumps(['hello' => 'world']); // {"hello": "world"} echo datetime::now()->isoformat(); // 2024-01-15T12:34:56.789012
Install additional packages, then use them:
composer pip install numpy
<?php use py\numpy; $arr = numpy::array([1, 2, 3, 4, 5]); echo numpy::mean($arr); // 3.0
For a complete Python β PHP syntax reference (kwargs, iteration, dicts, context managers, exceptions, and more) see docs/usage.md.
Package manager
The built-in package manager wraps uv pip with Composer integration.
# Install a package composer pip install requests # Install a specific version composer pip install "numpy:^1.24" # Install with extras composer pip install "requests[socks]" # Install with a custom PyPI index # (for PyTorch the right GPU index is normally picked automatically β see below) composer pip install torch --index-url https://download.pytorch.org/whl/rocm6.3 # Install a local package from a directory composer pip install /path/to/my-local-package # Uninstall a package composer pip uninstall requests # Upgrade a package composer pip install --upgrade numpy
Installed packages and their sources are saved to composer.json under extra.python-in-php.packages with an approximate constraint (e.g. ^2.32.3), while the exact resolved versions are pinned in python-in-php.lock next to composer.lock. On the next composer install packages are re-installed exactly from the lock file; without it they are resolved from the composer.json constraints and the lock file is created. Commit python-in-php.lock to version control to get reproducible installs across machines.
composer pip install --upgrade re-resolves versions within the composer.json constraints and updates only the lock file, mirroring how composer update treats PHP packages.
Running Python
The composer python command runs the project's Python interpreter (the one with your
installed packages), runs .py scripts, and manages the Python version:
# Run a script composer python path/to/script.py arg1 arg2 # Run a one-liner composer python -c "import numpy; print(numpy.__version__)" # Show the current Python version composer python use # Switch the Python version (recreates the environment and reinstalls packages) composer python use 3.12
The script's exit code is propagated. Note that --version/-V is intercepted by Composer
itself β use composer python use to see the managed version, or composer python -c "import sys; print(sys.version)".
Manual configuration
In addition to the composer pip commands you can configure Python-in-PHP manually in composer.json:
{
"extra": {
"python-in-php": {
"python-version": "3.12",
"packages": [
{"name": "requests", "version": "*"},
{"name": "numpy", "version": "^1.24"},
{
"name": "torch",
"version": "2.13.0+rocm7.2",
"index-url": "https://download.pytorch.org/whl/rocm7.2"
},
{
"name": "my-local-lib",
"version": "*",
"path": "/home/user/my-local-lib"
}
]
}
}
}
| Key | Type | Default | Description |
|---|---|---|---|
python-version |
string | "3.12" |
Python version to install |
packages |
array | [] |
List of packages to install |
packages[].name |
string | β | Package name |
packages[].version |
string | "*" |
Version constraint (Composer-style ^/~, PEP 440 or *) |
packages[].extras |
array | β | Package extras to install (the name[extra] pip syntax) |
packages[].index-url |
string | β | Custom PyPI index URL for this package |
packages[].path |
string | β | Absolute path to a local package directory |
PyTorch GPU backends
composer pip install torch picks the right PyTorch build for your hardware automatically:
- CUDA for NVIDIA
- ROCm for AMD
- Metal for Apple Silicon
- CPU backend when no GPU is present
To force a specific backend, set the PYTHON_IN_PHP_TORCH_BACKEND environment variable (e.g. cpu, cu128, rocm7.2, or none to disable the default) or pass --torch-backend=... explicitly:
PYTHON_IN_PHP_TORCH_BACKEND=cpu composer pip install torch
Runtime configuration
The Python worker is started lazily on the first Py:: call (or first generated class call).
To configure it, call Py::startIfNotStarted() before that first call, or set environment
variables β every option has one. Passing different options once the bridge exists throws a
LogicException rather than being silently ignored.
Py::startIfNotStarted([ 'log_file' => __DIR__ . '/var/log/python.log', 'timeout' => 3600, ]);
| Option | Environment variable | Default | Description |
|---|---|---|---|
log_file |
PYTHON_IN_PHP_LOG_FILE |
β | File the worker's stdout/stderr are appended to (see Logging) |
logger |
β | β | PSR-3 logger that receives the worker output and bridge events |
debug |
PYTHON_IN_PHP_DEBUG |
false |
Echo bridge traffic and let the worker inherit the PHP process's stdio |
timeout |
PYTHON_IN_PHP_TIMEOUT |
36000 |
Seconds to wait for a single Python call |
python_binary |
PYTHON_IN_PHP_PYTHON_BINARY |
managed venv | Interpreter used for the worker |
working_directory |
PYTHON_IN_PHP_WORKING_DIRECTORY |
getcwd() |
Working directory of the worker |
host / port |
PYTHON_IN_PHP_HOST / PYTHON_IN_PHP_PORT |
127.0.0.1 / free port |
Where the worker listens; an already listening server is reused |
Logging
By default the worker's stdout and stderr are discarded, so library warnings (LightGBM,
scikit-learn, transformersβ¦), progress bars and print() output are lost β as is the reason
a worker died. Give it a file, or a PSR-3 logger, or both:
// Everything Python writes to stdout/stderr, appended to this file Py::startIfNotStarted(['log_file' => '/var/log/app/python.log']); // Forwarded line by line to your logger after each call ("[python] β¦"), with the level // guessed from the text: tracebacks/errors β error, warnings β warning, everything else β info Py::startIfNotStarted(['logger' => $monolog]);
Python-level exceptions never depend on this β they always arrive as PythonException
with the full traceback. The log matters for everything that is not an exception.
Worker lifecycle
Py::isInstalled(); // true when the Python environment exists β a file check, no process spawned Py::isRunning(); // true when a worker is up and answering β never starts one Py::stop(); // terminate the worker; the next Py:: call starts a fresh one Py::restart(); // stop + start; every PythonObject obtained so far becomes stale
Py::isInstalled() is the hook for test suites that should not require Python:
if (!Py::isInstalled()) { $this->markTestSkipped('Python environment not installed β run composer install'); }
The same methods exist on the bridge instance (Py::bridge()->stop(), ->getWorkerPid(),
->getLogFile()). The worker is also terminated when the PHP process exits.
Using Python objects
Python objects are returned as PythonObject instances that support method calls and attribute access:
<?php use py\requests; $response = requests::get('https://httpbin.org/json'); $data = $response->json(); // method call echo $response->status_code; // attribute access
Python core from PHP
The Py facade exposes Python's exec/eval and its builtins directly β useful for the
things PHP has no equivalent for, or handles differently. It starts the Python worker
automatically on first use.
Py::eval('2 ** 10'); // 1024 Py::sum([1, 2, 3]); // 6 (Python sum(), not array_sum) Py::sorted([3, 1, 2], reverse: true); // [3, 2, 1] β PHP named args become Python kwargs Py::builtin('pow', 2, 8); // 256 β call any builtin by name
See docs/usage.md for the full list of helpers.
Context managers
Use Py::with() to run code inside a Python context manager β the equivalent of
Python's with statement. The context is exited afterwards even if the callback
throws.
<?php use py\builtins; // with open(...) as f: β the callback receives the entered value Py::with(builtins::open('/tmp/data.txt', 'a'), function ($f) { $f->write(' world'); }); // the file is closed automatically when the context exits
Py::with() returns whatever the callback returns.
PHP callbacks in Python
You can pass a PHP callable as an argument to any Python call. Python receives a
callable it can invoke synchronously β the PHP callback runs and its return value
is sent back β so functions like map, filter, sorted(key=...) and any API
that takes a callback work directly:
<?php use py\builtins; // A PHP closure invoked by Python's map() $doubled = builtins::list(builtins::map(fn ($x) => $x * 2, [1, 2, 3])); // [2, 4, 6] // As a sorting key $sorted = builtins::sorted(['ccc', 'a', 'bb'], key: fn ($w) => strlen($w)); // ['a', 'bb', 'ccc']
The callback may itself call back into Python (re-entrancy), and it can be stored by Python and invoked later:
<?php use py\functools; $adder = functools::partial(fn ($a, $b) => $a + $b, 10); echo $adder(5); // 15 β the PHP callback runs each time Python calls the partial
Most PHP callables are auto-detected as callbacks: a Closure, a first-class
callable (strlen(...)), a [$object, 'method'] pair, an invokable object, etc.
βΉοΈ Callable strings are not auto-detected. A plain string such as
'strlen'is ambiguous with ordinary string data, so it is passed to Python as a string. To use a named function (or force callback semantics for any value), wrap it inPy::callback():$lengths = builtins::map(Py::callback('strlen'), ['a', 'bb', 'ccc']);
If an exception is thrown inside the PHP callback, it propagates back to the original caller.
Exceptions
Python exceptions are thrown as Python_In_PHP\PythonException:
<?php use Python_In_PHP\PythonException; use py\json; try { json::loads('invalid json'); } catch (PythonException $e) { echo $e->getMessage(); // "Python error: Expecting value: line 1..." echo $e->traceback; // full Python traceback }
Worker crashes
If the worker process itself dies during a call (segfault in a native extension, the OOM
killer, sys.exit() inside library code), the call throws
Python_In_PHP\WorkerCrashedException instead. It carries the exit code or signal and the
last lines of the worker log when a log_file (or logger) is configured β without one,
the message tells you how to enable it. The session is torn down and the next Py:: call
starts a fresh worker; PythonObjects from the dead worker are stale and must not be reused.
use Python_In_PHP\WorkerCrashedException; try { $model->fit($x, $y); } catch (WorkerCrashedException $e) { $e->exitCode; // 137, or null when killed by a signal $e->signal; // 9, or null $e->logTail; // last ~40 lines of stdout/stderr }
A dropped connection while the worker is still alive (for example the WebSocket timeout)
is retried once; if that fails a RuntimeException is thrown.
AI model example
<?php use py\transformers; use py\torch; $model_name = 'google/gemma-3-4b-it'; $tokenizer = transformers\AutoTokenizer::from_pretrained($model_name); $model = transformers\AutoModelForCausalLM::from_pretrained( $model_name, torch_dtype: torch::$bfloat16, device_map: "auto" ); $messages = [ ['role' => 'user', 'content' => 'Why PHP is great?'] ]; $input_ids = $tokenizer->apply_chat_template( $messages, return_tensors: 'pt', add_generation_prompt: true ); $outputs = $model->generate($input_ids, max_new_tokens: 2048); $result = $tokenizer->decode($outputs[0], skip_special_tokens: true);
Or simpler with transformers pipeline:
<?php use py\transformers; use py\torch; $pipe = transformers\pipeline( 'text-generation', model: 'google/gemma-3-4b-it', torch_dtype: torch::$bfloat16, device_map: 'auto' ); $messages = [['role' => 'user', 'content' => 'Why PHP is great?']]; $output = $pipe($messages, max_new_tokens: 2048); $result = end($output[0]['generated_text'])['content'];
Docker
composer install downloads uv (~10 MB), a managed Python (~30 MB) and the packages. All of
that is driven by uv, so its own environment variables work β the library only fills in
defaults for the ones it needs and never overrides a value you set:
| Variable | Effect |
|---|---|
UV_CACHE_DIR |
Wheel/source cache β mount it as a build cache to skip re-downloading packages |
UV_PYTHON_INSTALL_DIR |
Where managed interpreters go (default vendor/bin/python-in-php/python) β mount it too |
UV_PYTHON_PREFERENCE |
Default only-managed; set system to use the image's own Python (e.g. python:3.12 base) instead of downloading one |
UV_BIN |
Path to an existing uv binary β skips the download entirely |
UV_OFFLINE |
1 disables all network access; requires UV_BIN (or uv on PATH) and a warm cache |
Typical Dockerfile:
FROM php:8.3-cli COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv ENV UV_BIN=/usr/local/bin/uv \ UV_CACHE_DIR=/root/.cache/uv \ UV_PYTHON_INSTALL_DIR=/opt/uv-python WORKDIR /app COPY composer.json composer.lock python-in-php.lock ./ RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=cache,target=/opt/uv-python \ composer install --no-dev --no-interaction COPY . .
If the image already has a matching Python (python:3.12 etc.), add UV_PYTHON_PREFERENCE=system
and drop the /opt/uv-python mount.
Run the worker with a log file so crashes are diagnosable from the container logs, e.g.
PYTHON_IN_PHP_LOG_FILE=/proc/self/fd/2 or a file under a mounted volume.
β οΈ The virtual environment records the interpreter it was created from, and the managed interpreter lives in
UV_PYTHON_INSTALL_DIR. In multi-stage builds copyvendor/to the same path it was built at and keepUV_PYTHON_INSTALL_DIRidentical β or runcomposer installin the final stage with the cache mounts above.
Troubleshooting
uv download fails / no internet access
The library downloads uv automatically during composer install. If your environment has no internet access, install uv manually before running Composer:
- macOS/Linux:
curl -Ls https://astral.sh/uv/install.sh | sh - Windows:
winget install astral-sh.uv
Then point the UV_BIN environment variable at the binary; if the download fails and UV_BIN is
not set, a uv found on PATH is used as a fallback. See Docker for caching and
UV_OFFLINE.
Python warnings, progress bars or prints are missing / a call ends with WorkerCrashedException
The worker's stdout/stderr go to /dev/null unless you configure a log: set
PYTHON_IN_PHP_LOG_FILE (or the log_file / logger options). See Logging.
"Class py\xxx not found"
PHPDoc stubs are generated in vendor/syncfly/python-in-php/py/. Run composer install to regenerate them after adding new packages.
"Could not import: β¦" during stub generation
Some modules can't be imported while their stubs are generated. Expected cases β optional modules that aren't installed, or platform-only modules on the wrong OS β are hidden; the remaining ones are summarized on a single line. Re-run with -v (e.g. composer install -v) to see the full error for each module.
"Python environment is not installed (β¦/python_bin/python)"
The managed interpreter is missing: run composer install (it creates the venv and the
python_bin symlink). Py::isInstalled() returns false in this state without starting anything.
"Python script was not found"
The Python binary symlink is missing. Remove vendor/bin/python-in-php/ and re-run composer install.
Python server does not start within 30 seconds
Check that the Python binary works: vendor/bin/python-in-php/python --version. If it fails, remove the environment and reinstall: rm -rf vendor/bin/python-in-php && composer install.
Permission denied on Windows
Symlink creation requires Administrator privileges or Windows Developer Mode. Run your terminal as Administrator, or enable Developer Mode in Windows Settings β System β Developer options.
License
This project is licensed under the Apache License 2.0.
You are free to use, modify and distribute it, including in commercial projects, provided you retain the copyright notice, state significant changes and include a copy of the license. The license also grants an explicit patent license.
Contributions submitted to this repository are licensed under the same terms.
"SyncFly" and "Python-in-PHP" are trademarks of the project maintainers and are not covered by the Apache License; see TRADEMARKS for what use is permitted without asking.
